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