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