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, resolve_programmatic_analysis_context,
15    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        resolved.ensure_not_cancelled("the boundary listing")?;
208        let changed_files = changed_files_for_run(&resolved)?;
209        let discovered = scoped_discovered_files(session.files(), changed_files.as_ref());
210        let data = compute_boundary_data(session.config(), Some(&discovered));
211
212        Ok(ListBoundariesProgrammaticOutput {
213            boundaries: boundary_data_to_output(&data),
214        })
215    })
216}
217
218/// Run project metadata listing through the API-owned runtime path.
219///
220/// # Errors
221///
222/// Returns a structured programmatic error for invalid options, config-load
223/// failures, or plugin regex errors, and `FALLOW_CANCELLED` when the caller's
224/// cancellation token is set. This route parses nothing: config load and file
225/// discovery are its only stage, so the token is observed on either side of
226/// that and nowhere within it.
227pub fn run_project_info(
228    options: &ProjectInfoOptions,
229) -> ProgrammaticResult<ProjectInfoProgrammaticOutput> {
230    let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
231    resolved.install(|| {
232        resolved.ensure_not_cancelled("config load and file discovery")?;
233        let project_config = load_list_project_config(&resolved)?;
234        let session = fallow_engine::session::AnalysisSession::from_config(project_config);
235        resolved.ensure_not_cancelled("the project listing")?;
236        let config = session.config();
237        let workspaces = session.workspaces();
238        let show_all = project_info_should_show_all(options);
239        let changed_files = changed_files_for_run(&resolved)?;
240        let discovered =
241            project_info_discovered_files(options, show_all, &session, changed_files.as_ref());
242        let discovered_ref = discovered.as_deref();
243
244        let inventory = collect_inventory(&session, options, show_all)?;
245        let (plugin_result, entry_points) = match inventory {
246            Some(inventory) => (
247                Some(inventory.plugins),
248                inventory
249                    .entry_points
250                    .map(|entries| scoped_entry_points(entries, changed_files.as_ref())),
251            ),
252            None => (None, None),
253        };
254        let plugin_diagnostics = plugin_result
255            .as_ref()
256            .map(|result| {
257                result
258                    .plugin_diagnostics(&config.root)
259                    .into_iter()
260                    .map(|diagnostic| diagnostic.into_root_relative(&config.root))
261                    .collect()
262            })
263            .unwrap_or_default();
264        let boundaries = options
265            .boundaries
266            .then(|| boundary_data_to_output(&compute_boundary_data(config, discovered_ref)));
267        let workspaces = if show_all {
268            Some(collect_workspace_output(
269                resolved.root(),
270                workspaces,
271                session.workspace_diagnostics(),
272            ))
273        } else {
274            None
275        };
276        let envelope = if boundaries.is_some() {
277            ListJsonEnvelope::Boundaries
278        } else {
279            ListJsonEnvelope::Plain
280        };
281
282        Ok(ProjectInfoProgrammaticOutput {
283            plugins: collect_plugins(options, show_all, plugin_result.as_ref()),
284            files: collect_files(options, show_all, discovered_ref, resolved.root()),
285            entry_points: entry_points
286                .map(|entries| entry_points_to_output(&entries, resolved.root())),
287            boundaries,
288            workspaces,
289            plugin_diagnostics,
290            envelope,
291        })
292    })
293}
294
295fn project_info_discovered_files(
296    options: &ProjectInfoOptions,
297    show_all: bool,
298    session: &fallow_engine::session::AnalysisSession,
299    changed_files: Option<&rustc_hash::FxHashSet<PathBuf>>,
300) -> Option<Vec<DiscoveredFile>> {
301    let needs_discovery =
302        options.files || options.entry_points || options.boundaries || options.plugins || show_all;
303    needs_discovery.then(|| scoped_discovered_files(session.files(), changed_files))
304}
305
306fn scoped_discovered_files(
307    files: &[DiscoveredFile],
308    changed_files: Option<&rustc_hash::FxHashSet<PathBuf>>,
309) -> Vec<DiscoveredFile> {
310    let Some(changed_files) = changed_files else {
311        return files.to_vec();
312    };
313    files
314        .iter()
315        .filter(|file| changed_files.contains(&file.path))
316        .cloned()
317        .collect()
318}
319
320fn load_list_project_config(
321    resolved: &crate::ProgrammaticAnalysisContext,
322) -> ProgrammaticResult<fallow_engine::project_config::ProjectConfig> {
323    fallow_engine::project_config::config_for_project_analysis(
324        resolved.root(),
325        resolved.config_path().as_deref(),
326        fallow_engine::project_config::ProjectConfigOptions {
327            output: fallow_types::output_format::OutputFormat::Json,
328            no_cache: resolved.no_cache(),
329            threads: resolved.threads(),
330            production_override: resolved.production_override(),
331            quiet: true,
332            analysis: fallow_config::ProductionAnalysis::DeadCode,
333            allow_remote_extends: resolved.allow_remote_extends(),
334        },
335    )
336    .map_err(|err| {
337        ProgrammaticError::new(format!("failed to load config: {err}"), 2)
338            .with_code("FALLOW_CONFIG_LOAD_FAILED")
339            .with_context("analysis.configPath")
340    })
341}
342
343const fn project_info_should_show_all(options: &ProjectInfoOptions) -> bool {
344    !options.entry_points && !options.files && !options.plugins && !options.boundaries
345}
346
347fn collect_plugins(
348    options: &ProjectInfoOptions,
349    show_all: bool,
350    plugin_result: Option<&fallow_engine::plugins::AggregatedPluginResult>,
351) -> Option<Vec<String>> {
352    if options.plugins || show_all {
353        plugin_result.map(|plugin_result| plugin_result.active_plugins().to_vec())
354    } else {
355        None
356    }
357}
358
359fn collect_files(
360    options: &ProjectInfoOptions,
361    show_all: bool,
362    discovered: Option<&[DiscoveredFile]>,
363    root: &Path,
364) -> Option<Vec<String>> {
365    if options.files || show_all {
366        discovered.map(|files| {
367            files
368                .iter()
369                .map(|file| display_relative(root, &file.path))
370                .collect()
371        })
372    } else {
373        None
374    }
375}
376
377/// Run the analysis prelude and entry-point discovery when the listing needs
378/// plugins or entry points, over the whole discovery: a changed-file scope
379/// narrows the listing, never which plugins are active.
380fn collect_inventory(
381    session: &fallow_engine::session::AnalysisSession,
382    options: &ProjectInfoOptions,
383    show_all: bool,
384) -> ProgrammaticResult<Option<fallow_engine::list_inventory::ListingInventory>> {
385    if !(options.plugins || options.entry_points || show_all) {
386        return Ok(None);
387    }
388    fallow_engine::list_inventory::collect_listing_inventory(
389        session,
390        options.entry_points || show_all,
391    )
392    .map(Some)
393    .map_err(|err| {
394        ProgrammaticError::new(err.message(), 2)
395            .with_code("FALLOW_PLUGIN_REGEX_FAILED")
396            .with_context("project_info.plugins")
397    })
398}
399
400/// Keep the entry points inside the changed-file scope, the way the listed
401/// files are kept. The analysis found them over the whole project.
402fn scoped_entry_points(
403    entry_points: Vec<EntryPoint>,
404    changed_files: Option<&rustc_hash::FxHashSet<PathBuf>>,
405) -> Vec<EntryPoint> {
406    let Some(changed_files) = changed_files else {
407        return entry_points;
408    };
409    entry_points
410        .into_iter()
411        .filter(|entry| changed_files.contains(&entry.path))
412        .collect()
413}
414
415fn entry_points_to_output(entries: &[EntryPoint], root: &Path) -> Vec<ListEntryPointOutput> {
416    entries
417        .iter()
418        .map(|entry| ListEntryPointOutput {
419            path: display_relative(root, &entry.path),
420            source: entry.source.to_string(),
421        })
422        .collect()
423}
424
425fn collect_workspace_output(
426    root: &Path,
427    workspaces: &[fallow_config::WorkspaceInfo],
428    diagnostics: &[fallow_config::WorkspaceDiagnostic],
429) -> WorkspacesOutput<fallow_config::WorkspaceDiagnostic> {
430    let workspaces = workspaces
431        .iter()
432        .map(|workspace| {
433            let relative = workspace.root.strip_prefix(root).unwrap_or(&workspace.root);
434            WorkspaceOutputInfo {
435                name: workspace.name.clone(),
436                path: relative.display().to_string().replace('\\', "/"),
437                is_internal_dependency: workspace.is_internal_dependency,
438            }
439        })
440        .collect::<Vec<_>>();
441    WorkspacesOutput {
442        workspace_count: workspaces.len(),
443        workspaces,
444        // Project-relative like the sibling `workspaces[].path` and like every
445        // analysis envelope's `workspace_diagnostics[]`. The list envelope has
446        // no post-serialization `strip_root_prefix` pass, so it normalises here.
447        workspace_diagnostics: diagnostics
448            .iter()
449            .map(|diagnostic| diagnostic.clone().into_root_relative(root))
450            .collect(),
451    }
452}
453
454/// Compute boundary listing data from resolved config and optional discovery.
455#[must_use]
456pub fn compute_boundary_data(
457    config: &fallow_config::ResolvedConfig,
458    discovered: Option<&[DiscoveredFile]>,
459) -> BoundaryData {
460    let boundaries = &config.boundaries;
461
462    if boundaries.is_empty() {
463        return BoundaryData {
464            zones: vec![],
465            rules: vec![],
466            logical_groups: vec![],
467            is_empty: true,
468        };
469    }
470
471    let zones = build_boundary_zones(config, discovered);
472    let rules = build_boundary_rules(boundaries);
473    let logical_groups = build_logical_groups(boundaries, &zones);
474
475    BoundaryData {
476        zones,
477        rules,
478        logical_groups,
479        is_empty: false,
480    }
481}
482
483fn build_boundary_zones(
484    config: &fallow_config::ResolvedConfig,
485    discovered: Option<&[DiscoveredFile]>,
486) -> Vec<ZoneInfo> {
487    config
488        .boundaries
489        .zones
490        .iter()
491        .map(|zone| ZoneInfo {
492            name: zone.name.clone(),
493            patterns: zone.matchers.iter().map(|m| m.glob().to_string()).collect(),
494            file_count: count_boundary_zone_files(config, discovered, &zone.name),
495        })
496        .collect()
497}
498
499fn count_boundary_zone_files(
500    config: &fallow_config::ResolvedConfig,
501    discovered: Option<&[DiscoveredFile]>,
502    zone_name: &str,
503) -> usize {
504    discovered.map_or(0, |files| {
505        files
506            .iter()
507            .filter(|file| {
508                let rel = file
509                    .path
510                    .strip_prefix(&config.root)
511                    .ok()
512                    .map(|path| path.to_string_lossy().replace('\\', "/"));
513                rel.is_some_and(|path| config.boundaries.classify_zone(&path) == Some(zone_name))
514            })
515            .count()
516    })
517}
518
519fn build_boundary_rules(boundaries: &ResolvedBoundaryConfig) -> Vec<RuleInfo> {
520    boundaries
521        .rules
522        .iter()
523        .map(|rule| RuleInfo {
524            from: rule.from_zone.clone(),
525            allow: rule.allowed_zones.clone(),
526        })
527        .collect()
528}
529
530fn build_logical_groups(
531    boundaries: &ResolvedBoundaryConfig,
532    zones: &[ZoneInfo],
533) -> Vec<LogicalGroupInfo> {
534    let zone_count_by_name: FxHashMap<&str, usize> = zones
535        .iter()
536        .map(|zone| (zone.name.as_str(), zone.file_count))
537        .collect();
538
539    boundaries
540        .logical_groups
541        .iter()
542        .map(|group| logical_group_info(group, &zone_count_by_name))
543        .collect()
544}
545
546fn logical_group_info(
547    group: &LogicalGroup,
548    zone_count_by_name: &FxHashMap<&str, usize>,
549) -> LogicalGroupInfo {
550    let child_file_count: usize = group
551        .children
552        .iter()
553        .filter_map(|child| zone_count_by_name.get(child.as_str()).copied())
554        .sum();
555    let fallback_file_count = group
556        .fallback_zone
557        .as_deref()
558        .and_then(|fallback| zone_count_by_name.get(fallback).copied())
559        .unwrap_or(0);
560
561    LogicalGroupInfo {
562        name: group.name.clone(),
563        children: group.children.clone(),
564        auto_discover: group.auto_discover.clone(),
565        authored_rule: group.authored_rule.clone(),
566        fallback_zone: group.fallback_zone.clone(),
567        source_zone_index: group.source_zone_index,
568        status: group.status,
569        file_count: child_file_count + fallback_file_count,
570        child_file_count,
571        fallback_file_count,
572        merged_from: group.merged_from.clone(),
573        original_zone_root: group.original_zone_root.clone(),
574        child_source_indices: group.child_source_indices.clone(),
575    }
576}
577
578/// Convert boundary listing data to the stable output contract.
579#[must_use]
580pub fn boundary_data_to_output(data: &BoundaryData) -> BoundariesListing {
581    if data.is_empty {
582        return BoundariesListing {
583            configured: false,
584            zone_count: 0,
585            zones: Vec::new(),
586            rule_count: 0,
587            rules: Vec::new(),
588            logical_group_count: 0,
589            logical_groups: Vec::new(),
590        };
591    }
592
593    BoundariesListing {
594        configured: true,
595        zone_count: data.zones.len(),
596        zones: data
597            .zones
598            .iter()
599            .map(|zone| BoundariesListZone {
600                name: zone.name.clone(),
601                patterns: zone.patterns.clone(),
602                file_count: zone.file_count,
603            })
604            .collect(),
605        rule_count: data.rules.len(),
606        rules: data
607            .rules
608            .iter()
609            .map(|rule| BoundariesListRule {
610                from: rule.from.clone(),
611                allow: rule.allow.clone(),
612            })
613            .collect(),
614        logical_group_count: data.logical_groups.len(),
615        logical_groups: data
616            .logical_groups
617            .iter()
618            .map(logical_group_info_to_output)
619            .collect(),
620    }
621}
622
623fn logical_group_info_to_output(group: &LogicalGroupInfo) -> BoundariesListLogicalGroup {
624    BoundariesListLogicalGroup {
625        name: group.name.clone(),
626        children: group.children.clone(),
627        auto_discover: group.auto_discover.clone(),
628        status: group.status,
629        source_zone_index: group.source_zone_index,
630        file_count: group.file_count,
631        authored_rule: group.authored_rule.clone(),
632        fallback_zone: group.fallback_zone.clone(),
633        merged_from: group.merged_from.clone(),
634        original_zone_root: group.original_zone_root.clone(),
635        child_source_indices: group.child_source_indices.clone(),
636    }
637}
638
639#[cfg(test)]
640mod tests {
641    use std::process::Command;
642
643    use serde_json::json;
644
645    use super::*;
646
647    /// The `fallow workspaces` / `fallow list --workspaces` envelope and the
648    /// MCP `project_info` tool have no post-serialization `strip_root_prefix`
649    /// pass, so `collect_workspace_output` is what makes their diagnostic
650    /// paths project-relative like every other envelope's.
651    #[test]
652    fn workspace_output_emits_project_relative_diagnostic_paths() {
653        let root = Path::new("/project");
654        let output = collect_workspace_output(
655            root,
656            &[],
657            &[fallow_config::WorkspaceDiagnostic::new(
658                root,
659                root.join("packages/inner"),
660                fallow_config::WorkspaceDiagnosticKind::UndeclaredWorkspace,
661            )],
662        );
663
664        let value = serde_json::to_value(&output).expect("workspaces output serializes");
665        assert_eq!(value["workspace_diagnostics"][0]["path"], "packages/inner");
666    }
667
668    fn empty_boundary_data() -> BoundaryData {
669        BoundaryData {
670            zones: vec![],
671            rules: vec![],
672            logical_groups: vec![],
673            is_empty: true,
674        }
675    }
676
677    fn boundary_data_to_json(data: &BoundaryData) -> serde_json::Value {
678        serde_json::to_value(boundary_data_to_output(data))
679            .expect("boundary list output should serialize")
680    }
681
682    fn git(project: &Path, args: &[&str]) {
683        let status = fallow_engine::changed_files::clear_ambient_git_env(&mut Command::new("git"))
684            .args(args)
685            .current_dir(project)
686            .status()
687            .expect("git command should run");
688        assert!(status.success(), "git {args:?} failed");
689    }
690
691    fn setup_changed_boundary_project() -> tempfile::TempDir {
692        let project = tempfile::tempdir().expect("project");
693        std::fs::write(
694            project.path().join("package.json"),
695            r#"{"name":"changed-list-api","main":"src/app/index.ts"}"#,
696        )
697        .expect("write package");
698        std::fs::write(
699            project.path().join(".fallowrc.json"),
700            r#"{
701                "boundaries": {
702                    "zones": [
703                        { "name": "app", "patterns": ["src/app/**"] },
704                        { "name": "shared", "patterns": ["src/shared/**"] }
705                    ]
706                }
707            }"#,
708        )
709        .expect("write config");
710        std::fs::create_dir_all(project.path().join("src/app")).expect("create app");
711        std::fs::create_dir_all(project.path().join("src/shared")).expect("create shared");
712        std::fs::write(
713            project.path().join("src/app/index.ts"),
714            "export const app = 1;\n",
715        )
716        .expect("write app");
717        std::fs::write(
718            project.path().join("src/shared/index.ts"),
719            "export const shared = 1;\n",
720        )
721        .expect("write shared");
722
723        git(project.path(), &["init", "-q"]);
724        git(
725            project.path(),
726            &["config", "user.email", "test@example.com"],
727        );
728        git(project.path(), &["config", "user.name", "Test User"]);
729        git(project.path(), &["config", "commit.gpgsign", "false"]);
730        git(project.path(), &["add", "."]);
731        git(project.path(), &["commit", "-qm", "initial"]);
732        std::fs::write(
733            project.path().join("src/app/index.ts"),
734            "export const app = 2;\n",
735        )
736        .expect("modify app");
737        project
738    }
739
740    #[test]
741    fn project_info_default_sections_match_plain_list_contract() {
742        let project = tempfile::tempdir().expect("project");
743        std::fs::write(
744            project.path().join("package.json"),
745            r#"{"name":"project-info-api","main":"src/index.ts"}"#,
746        )
747        .expect("write package");
748        std::fs::create_dir_all(project.path().join("src")).expect("create src");
749        std::fs::write(
750            project.path().join("src/index.ts"),
751            "export const value = 1;\n",
752        )
753        .expect("write source");
754
755        let output = serialize_project_info_programmatic_json(
756            run_project_info(&ProjectInfoOptions {
757                analysis: AnalysisOptions {
758                    root: Some(project.path().to_path_buf()),
759                    no_cache: true,
760                    ..AnalysisOptions::default()
761                },
762                ..ProjectInfoOptions::default()
763            })
764            .expect("project info should run"),
765        )
766        .expect("project info should serialize");
767
768        assert_eq!(output["file_count"], 1);
769        assert_eq!(output["files"][0], "src/index.ts");
770        assert_eq!(output["entry_point_count"], 1);
771        assert_eq!(output["workspace_count"], 0);
772        assert!(output.get("kind").is_none());
773    }
774
775    #[test]
776    fn project_info_surfaces_malformed_root_package_json() {
777        let project = tempfile::tempdir().expect("project");
778        std::fs::write(project.path().join("package.json"), "{").expect("write package");
779
780        let err = run_project_info(&ProjectInfoOptions {
781            analysis: AnalysisOptions {
782                root: Some(project.path().to_path_buf()),
783                no_cache: true,
784                ..AnalysisOptions::default()
785            },
786            ..ProjectInfoOptions::default()
787        })
788        .expect_err("malformed root package.json must fail project info");
789
790        assert_eq!(err.exit_code, 2);
791        assert_eq!(err.code.as_deref(), Some("FALLOW_CONFIG_LOAD_FAILED"));
792        assert!(
793            err.message.contains("package.json"),
794            "error should name the malformed root package.json"
795        );
796    }
797
798    #[test]
799    fn project_info_default_sections_include_undeclared_workspace_diagnostic() {
800        let project = tempfile::tempdir().expect("project");
801        std::fs::write(
802            project.path().join("package.json"),
803            r#"{"name":"project-info-api","workspaces":["packages/*"]}"#,
804        )
805        .expect("write package");
806        std::fs::create_dir_all(project.path().join("packages/app")).expect("workspace dir");
807        std::fs::write(
808            project.path().join("packages/app/package.json"),
809            r#"{"name":"app","main":"src/index.ts"}"#,
810        )
811        .expect("write workspace package");
812        std::fs::create_dir_all(project.path().join("tools/extra")).expect("extra package dir");
813        std::fs::write(
814            project.path().join("tools/extra/package.json"),
815            r#"{"name":"extra"}"#,
816        )
817        .expect("write extra package");
818
819        let output = serialize_project_info_programmatic_json(
820            run_project_info(&ProjectInfoOptions {
821                analysis: AnalysisOptions {
822                    root: Some(project.path().to_path_buf()),
823                    no_cache: true,
824                    ..AnalysisOptions::default()
825                },
826                ..ProjectInfoOptions::default()
827            })
828            .expect("project info should run"),
829        )
830        .expect("project info should serialize");
831
832        let diagnostics = output["workspace_diagnostics"]
833            .as_array()
834            .expect("project info should include workspace_diagnostics");
835        assert!(
836            diagnostics.iter().any(|diagnostic| {
837                diagnostic["kind"].as_str() == Some("undeclared-workspace")
838                    // Project-relative, like every other envelope's
839                    // `workspace_diagnostics[].path` (issue #2366 follow-up).
840                    && diagnostic["path"].as_str() == Some("tools/extra")
841            }),
842            "project info must include undeclared workspace diagnostics from the reused session, got {diagnostics:#?}"
843        );
844    }
845
846    /// Issue #2366: the MCP `project_info` tool reads the same workspace value
847    /// the `fallow workspaces` envelope does, so a glob declared in both
848    /// `package.json` and `pnpm-workspace.yaml` must reach an agent as one
849    /// entry per directory, project-relative, exactly as the CLI reports it.
850    #[test]
851    fn project_info_reports_one_entry_per_directory_for_a_glob_in_two_manifests() {
852        let project = tempfile::tempdir().expect("project");
853        let root = project.path();
854        std::fs::create_dir_all(root.join("pkgs/aaa")).expect("first package-less dir");
855        std::fs::create_dir_all(root.join("pkgs/bbb")).expect("second package-less dir");
856        std::fs::create_dir_all(root.join("src")).expect("source dir");
857        std::fs::write(
858            root.join("package.json"),
859            r#"{"name":"two-manifest-root","private":true,"workspaces":["./pkgs/*"]}"#,
860        )
861        .expect("write root manifest");
862        std::fs::write(
863            root.join("pnpm-workspace.yaml"),
864            "packages:\n  - \"pkgs/*\"\n",
865        )
866        .expect("write pnpm workspace manifest");
867        std::fs::write(root.join("src/index.ts"), "export const value = 1;\n")
868            .expect("write source");
869
870        let output = serialize_project_info_programmatic_json(
871            run_project_info(&ProjectInfoOptions {
872                analysis: AnalysisOptions {
873                    root: Some(root.to_path_buf()),
874                    no_cache: true,
875                    ..AnalysisOptions::default()
876                },
877                ..ProjectInfoOptions::default()
878            })
879            .expect("project info should run"),
880        )
881        .expect("project info should serialize");
882
883        let reported: Vec<(&str, &str)> = output["workspace_diagnostics"]
884            .as_array()
885            .expect("project info should include workspace_diagnostics")
886            .iter()
887            .filter(|diagnostic| diagnostic["kind"] == "glob-matched-no-package-json")
888            .map(|diagnostic| {
889                (
890                    diagnostic["pattern"].as_str().unwrap_or_default(),
891                    diagnostic["path"].as_str().unwrap_or_default(),
892                )
893            })
894            .collect();
895
896        assert_eq!(
897            reported,
898            vec![("pkgs/*", "pkgs/aaa"), ("pkgs/*", "pkgs/bbb")],
899            "project_info agrees with the CLI envelopes: {}",
900            output["workspace_diagnostics"]
901        );
902    }
903
904    #[test]
905    fn list_runtimes_scope_files_and_boundary_counts_to_changed_since() {
906        let project = setup_changed_boundary_project();
907        let analysis = AnalysisOptions {
908            root: Some(project.path().to_path_buf()),
909            changed_since: Some("HEAD".to_string()),
910            no_cache: true,
911            ..AnalysisOptions::default()
912        };
913
914        let project_info = serialize_project_info_programmatic_json(
915            run_project_info(&ProjectInfoOptions {
916                analysis: analysis.clone(),
917                files: true,
918                boundaries: true,
919                ..ProjectInfoOptions::default()
920            })
921            .expect("project info should run"),
922        )
923        .expect("project info should serialize");
924        let files = project_info["files"].as_array().expect("files array");
925        assert_eq!(files, &[json!("src/app/index.ts")]);
926        assert_eq!(project_info["boundaries"]["zones"][0]["file_count"], 1);
927        assert_eq!(project_info["boundaries"]["zones"][1]["file_count"], 0);
928
929        let boundaries = serialize_list_boundaries_programmatic_json(
930            run_list_boundaries(&ListBoundariesOptions { analysis })
931                .expect("list boundaries should run"),
932        )
933        .expect("list boundaries should serialize");
934        assert_eq!(boundaries["boundaries"]["zones"][0]["file_count"], 1);
935        assert_eq!(boundaries["boundaries"]["zones"][1]["file_count"], 0);
936    }
937
938    #[test]
939    fn boundary_json_empty_includes_logical_groups_key() {
940        let value = boundary_data_to_json(&empty_boundary_data());
941
942        assert_eq!(value["configured"], false);
943        assert_eq!(value["zone_count"], 0);
944        assert_eq!(value["rule_count"], 0);
945        assert_eq!(value["logical_group_count"], 0);
946        assert_eq!(value["logical_groups"], json!([]));
947    }
948
949    #[test]
950    fn boundary_json_logical_group_carries_all_fields() {
951        let data = BoundaryData {
952            zones: vec![ZoneInfo {
953                name: "features/auth".to_string(),
954                patterns: vec!["src/features/auth/**".to_string()],
955                file_count: 3,
956            }],
957            rules: vec![],
958            logical_groups: vec![LogicalGroupInfo {
959                name: "features".to_string(),
960                children: vec!["features/auth".to_string()],
961                auto_discover: vec!["./src/features/".to_string()],
962                authored_rule: Some(AuthoredRule {
963                    allow: vec!["shared".to_string()],
964                    allow_type_only: vec!["types".to_string()],
965                }),
966                fallback_zone: None,
967                source_zone_index: 1,
968                status: LogicalGroupStatus::Ok,
969                file_count: 3,
970                child_file_count: 3,
971                fallback_file_count: 0,
972                merged_from: None,
973                original_zone_root: None,
974                child_source_indices: vec![],
975            }],
976            is_empty: false,
977        };
978
979        let value = boundary_data_to_json(&data);
980        let group = &value["logical_groups"][0];
981
982        assert_eq!(value["logical_group_count"], 1);
983        assert_eq!(group["name"], "features");
984        assert_eq!(group["children"][0], "features/auth");
985        assert_eq!(group["auto_discover"][0], "./src/features/");
986        assert_eq!(group["status"], "ok");
987        assert_eq!(group["source_zone_index"], 1);
988        assert_eq!(group["file_count"], 3);
989        assert_eq!(group["authored_rule"]["allow"][0], "shared");
990        assert_eq!(group["authored_rule"]["allow_type_only"][0], "types");
991        assert!(group.get("fallback_zone").is_none());
992        assert!(group.get("merged_from").is_none());
993        assert!(group.get("original_zone_root").is_none());
994        assert!(group.get("child_source_indices").is_none());
995    }
996
997    #[test]
998    fn boundary_json_logical_group_optional_fields_round_trip() {
999        let data = BoundaryData {
1000            zones: vec![],
1001            rules: vec![],
1002            logical_groups: vec![LogicalGroupInfo {
1003                name: "features".to_string(),
1004                children: vec!["features/auth".to_string(), "features/billing".to_string()],
1005                auto_discover: vec!["src/features".to_string(), "src/modules".to_string()],
1006                authored_rule: None,
1007                fallback_zone: Some("features".to_string()),
1008                source_zone_index: 0,
1009                status: LogicalGroupStatus::Empty,
1010                file_count: 2,
1011                child_file_count: 0,
1012                fallback_file_count: 2,
1013                merged_from: Some(vec![0, 3]),
1014                original_zone_root: Some("packages/app/".to_string()),
1015                child_source_indices: vec![0, 1],
1016            }],
1017            is_empty: false,
1018        };
1019
1020        let group = &boundary_data_to_json(&data)["logical_groups"][0];
1021
1022        assert_eq!(group["status"], "empty");
1023        assert_eq!(group["fallback_zone"], "features");
1024        assert_eq!(group["merged_from"][1], 3);
1025        assert_eq!(group["original_zone_root"], "packages/app/");
1026        assert_eq!(group["child_source_indices"][1], 1);
1027    }
1028}