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