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 changed_files = changed_files_for_run(&resolved)?;
192        let discovered =
193            project_info_discovered_files(options, show_all, &session, changed_files.as_ref());
194        let discovered_ref = discovered.as_deref();
195
196        let plugin_result = collect_plugin_result(
197            resolved.root(),
198            config,
199            options,
200            show_all,
201            discovered_ref,
202            workspaces,
203        )?;
204        let entry_points = collect_entry_points(
205            config,
206            options,
207            show_all,
208            discovered_ref,
209            workspaces,
210            plugin_result.as_ref(),
211        );
212        let boundaries = options
213            .boundaries
214            .then(|| boundary_data_to_output(&compute_boundary_data(config, discovered_ref)));
215        let workspaces = if show_all {
216            Some(collect_workspace_output(
217                resolved.root(),
218                workspaces,
219                &session.current_workspace_diagnostics(),
220            ))
221        } else {
222            None
223        };
224        let envelope = if boundaries.is_some() {
225            ListJsonEnvelope::Boundaries
226        } else {
227            ListJsonEnvelope::Plain
228        };
229
230        Ok(ProjectInfoProgrammaticOutput {
231            plugins: collect_plugins(options, show_all, plugin_result.as_ref()),
232            files: collect_files(options, show_all, discovered_ref, resolved.root()),
233            entry_points: entry_points
234                .map(|entries| entry_points_to_output(&entries, resolved.root())),
235            boundaries,
236            workspaces,
237            envelope,
238            envelope_mode: RootEnvelopeMode::Tagged,
239        })
240    })
241}
242
243fn project_info_discovered_files(
244    options: &ProjectInfoOptions,
245    show_all: bool,
246    session: &fallow_engine::session::AnalysisSession,
247    changed_files: Option<&rustc_hash::FxHashSet<PathBuf>>,
248) -> Option<Vec<DiscoveredFile>> {
249    let needs_discovery =
250        options.files || options.entry_points || options.boundaries || options.plugins || show_all;
251    needs_discovery.then(|| scoped_discovered_files(session.files(), changed_files))
252}
253
254fn scoped_discovered_files(
255    files: &[DiscoveredFile],
256    changed_files: Option<&rustc_hash::FxHashSet<PathBuf>>,
257) -> Vec<DiscoveredFile> {
258    let Some(changed_files) = changed_files else {
259        return files.to_vec();
260    };
261    files
262        .iter()
263        .filter(|file| changed_files.contains(&file.path))
264        .cloned()
265        .collect()
266}
267
268fn load_list_project_config(
269    resolved: &crate::ProgrammaticAnalysisContext,
270) -> ProgrammaticResult<fallow_engine::project_config::ProjectConfig> {
271    fallow_engine::project_config::config_for_project_analysis(
272        resolved.root(),
273        resolved.config_path().as_deref(),
274        fallow_engine::project_config::ProjectConfigOptions {
275            output: fallow_types::output_format::OutputFormat::Json,
276            no_cache: resolved.no_cache(),
277            threads: resolved.threads(),
278            production_override: resolved.production_override(),
279            quiet: true,
280            analysis: fallow_config::ProductionAnalysis::DeadCode,
281            allow_remote_extends: resolved.allow_remote_extends(),
282        },
283    )
284    .map_err(|err| {
285        ProgrammaticError::new(format!("failed to load config: {err}"), 2)
286            .with_code("FALLOW_CONFIG_LOAD_FAILED")
287            .with_context("analysis.configPath")
288    })
289}
290
291const fn project_info_should_show_all(options: &ProjectInfoOptions) -> bool {
292    !options.entry_points && !options.files && !options.plugins && !options.boundaries
293}
294
295fn collect_plugins(
296    options: &ProjectInfoOptions,
297    show_all: bool,
298    plugin_result: Option<&fallow_engine::plugins::AggregatedPluginResult>,
299) -> Option<Vec<String>> {
300    if options.plugins || show_all {
301        plugin_result.map(|plugin_result| plugin_result.active_plugins().to_vec())
302    } else {
303        None
304    }
305}
306
307fn collect_files(
308    options: &ProjectInfoOptions,
309    show_all: bool,
310    discovered: Option<&[DiscoveredFile]>,
311    root: &Path,
312) -> Option<Vec<String>> {
313    if options.files || show_all {
314        discovered.map(|files| {
315            files
316                .iter()
317                .map(|file| format_display_path(&file.path, root))
318                .collect()
319        })
320    } else {
321        None
322    }
323}
324
325fn collect_plugin_result(
326    root: &Path,
327    config: &fallow_config::ResolvedConfig,
328    options: &ProjectInfoOptions,
329    show_all: bool,
330    discovered: Option<&[DiscoveredFile]>,
331    workspaces: &[fallow_config::WorkspaceInfo],
332) -> ProgrammaticResult<Option<fallow_engine::plugins::AggregatedPluginResult>> {
333    if !(options.plugins || options.entry_points || show_all) {
334        return Ok(None);
335    }
336    let Some(files) = discovered else {
337        return Ok(None);
338    };
339    fallow_engine::list_inventory::collect_active_plugins(root, config, files, workspaces)
340        .map(Some)
341        .map_err(|err| match err {
342            fallow_engine::list_inventory::ListInventoryError::PluginRegex(errors) => {
343                ProgrammaticError::new(
344                    fallow_engine::plugins::registry::format_plugin_regex_errors(&errors),
345                    2,
346                )
347                .with_code("FALLOW_PLUGIN_REGEX_FAILED")
348                .with_context("project_info.plugins")
349            }
350        })
351}
352
353fn collect_entry_points(
354    config: &fallow_config::ResolvedConfig,
355    options: &ProjectInfoOptions,
356    show_all: bool,
357    discovered: Option<&[DiscoveredFile]>,
358    workspaces: &[fallow_config::WorkspaceInfo],
359    plugin_result: Option<&fallow_engine::plugins::AggregatedPluginResult>,
360) -> Option<Vec<EntryPoint>> {
361    if !(options.entry_points || show_all) {
362        return None;
363    }
364    let discovered = discovered?;
365    Some(fallow_engine::list_inventory::collect_entry_points(
366        config,
367        discovered,
368        workspaces,
369        plugin_result,
370    ))
371}
372
373fn entry_points_to_output(entries: &[EntryPoint], root: &Path) -> Vec<ListEntryPointOutput> {
374    entries
375        .iter()
376        .map(|entry| ListEntryPointOutput {
377            path: format_display_path(&entry.path, root),
378            source: entry.source.to_string(),
379        })
380        .collect()
381}
382
383fn collect_workspace_output(
384    root: &Path,
385    workspaces: &[fallow_config::WorkspaceInfo],
386    diagnostics: &[fallow_config::WorkspaceDiagnostic],
387) -> WorkspacesOutput<fallow_config::WorkspaceDiagnostic> {
388    let workspaces = workspaces
389        .iter()
390        .map(|workspace| {
391            let relative = workspace.root.strip_prefix(root).unwrap_or(&workspace.root);
392            WorkspaceOutputInfo {
393                name: workspace.name.clone(),
394                path: relative.display().to_string().replace('\\', "/"),
395                is_internal_dependency: workspace.is_internal_dependency,
396            }
397        })
398        .collect::<Vec<_>>();
399    WorkspacesOutput {
400        workspace_count: workspaces.len(),
401        workspaces,
402        workspace_diagnostics: diagnostics.to_vec(),
403    }
404}
405
406fn format_display_path(path: &Path, root: &Path) -> String {
407    path.strip_prefix(root)
408        .unwrap_or(path)
409        .display()
410        .to_string()
411        .replace('\\', "/")
412}
413
414/// Compute boundary listing data from resolved config and optional discovery.
415#[must_use]
416pub fn compute_boundary_data(
417    config: &fallow_config::ResolvedConfig,
418    discovered: Option<&[DiscoveredFile]>,
419) -> BoundaryData {
420    let boundaries = &config.boundaries;
421
422    if boundaries.is_empty() {
423        return BoundaryData {
424            zones: vec![],
425            rules: vec![],
426            logical_groups: vec![],
427            is_empty: true,
428        };
429    }
430
431    let zones = build_boundary_zones(config, discovered);
432    let rules = build_boundary_rules(boundaries);
433    let logical_groups = build_logical_groups(boundaries, &zones);
434
435    BoundaryData {
436        zones,
437        rules,
438        logical_groups,
439        is_empty: false,
440    }
441}
442
443fn build_boundary_zones(
444    config: &fallow_config::ResolvedConfig,
445    discovered: Option<&[DiscoveredFile]>,
446) -> Vec<ZoneInfo> {
447    config
448        .boundaries
449        .zones
450        .iter()
451        .map(|zone| ZoneInfo {
452            name: zone.name.clone(),
453            patterns: zone.matchers.iter().map(|m| m.glob().to_string()).collect(),
454            file_count: count_boundary_zone_files(config, discovered, &zone.name),
455        })
456        .collect()
457}
458
459fn count_boundary_zone_files(
460    config: &fallow_config::ResolvedConfig,
461    discovered: Option<&[DiscoveredFile]>,
462    zone_name: &str,
463) -> usize {
464    discovered.map_or(0, |files| {
465        files
466            .iter()
467            .filter(|file| {
468                let rel = file
469                    .path
470                    .strip_prefix(&config.root)
471                    .ok()
472                    .map(|path| path.to_string_lossy().replace('\\', "/"));
473                rel.is_some_and(|path| config.boundaries.classify_zone(&path) == Some(zone_name))
474            })
475            .count()
476    })
477}
478
479fn build_boundary_rules(boundaries: &ResolvedBoundaryConfig) -> Vec<RuleInfo> {
480    boundaries
481        .rules
482        .iter()
483        .map(|rule| RuleInfo {
484            from: rule.from_zone.clone(),
485            allow: rule.allowed_zones.clone(),
486        })
487        .collect()
488}
489
490fn build_logical_groups(
491    boundaries: &ResolvedBoundaryConfig,
492    zones: &[ZoneInfo],
493) -> Vec<LogicalGroupInfo> {
494    let zone_count_by_name: FxHashMap<&str, usize> = zones
495        .iter()
496        .map(|zone| (zone.name.as_str(), zone.file_count))
497        .collect();
498
499    boundaries
500        .logical_groups
501        .iter()
502        .map(|group| logical_group_info(group, &zone_count_by_name))
503        .collect()
504}
505
506fn logical_group_info(
507    group: &LogicalGroup,
508    zone_count_by_name: &FxHashMap<&str, usize>,
509) -> LogicalGroupInfo {
510    let child_file_count: usize = group
511        .children
512        .iter()
513        .filter_map(|child| zone_count_by_name.get(child.as_str()).copied())
514        .sum();
515    let fallback_file_count = group
516        .fallback_zone
517        .as_deref()
518        .and_then(|fallback| zone_count_by_name.get(fallback).copied())
519        .unwrap_or(0);
520
521    LogicalGroupInfo {
522        name: group.name.clone(),
523        children: group.children.clone(),
524        auto_discover: group.auto_discover.clone(),
525        authored_rule: group.authored_rule.clone(),
526        fallback_zone: group.fallback_zone.clone(),
527        source_zone_index: group.source_zone_index,
528        status: group.status,
529        file_count: child_file_count + fallback_file_count,
530        child_file_count,
531        fallback_file_count,
532        merged_from: group.merged_from.clone(),
533        original_zone_root: group.original_zone_root.clone(),
534        child_source_indices: group.child_source_indices.clone(),
535    }
536}
537
538/// Convert boundary listing data to the stable output contract.
539#[must_use]
540pub fn boundary_data_to_output(data: &BoundaryData) -> BoundariesListing {
541    if data.is_empty {
542        return BoundariesListing {
543            configured: false,
544            zone_count: 0,
545            zones: Vec::new(),
546            rule_count: 0,
547            rules: Vec::new(),
548            logical_group_count: 0,
549            logical_groups: Vec::new(),
550        };
551    }
552
553    BoundariesListing {
554        configured: true,
555        zone_count: data.zones.len(),
556        zones: data
557            .zones
558            .iter()
559            .map(|zone| BoundariesListZone {
560                name: zone.name.clone(),
561                patterns: zone.patterns.clone(),
562                file_count: zone.file_count,
563            })
564            .collect(),
565        rule_count: data.rules.len(),
566        rules: data
567            .rules
568            .iter()
569            .map(|rule| BoundariesListRule {
570                from: rule.from.clone(),
571                allow: rule.allow.clone(),
572            })
573            .collect(),
574        logical_group_count: data.logical_groups.len(),
575        logical_groups: data
576            .logical_groups
577            .iter()
578            .map(logical_group_info_to_output)
579            .collect(),
580    }
581}
582
583fn logical_group_info_to_output(group: &LogicalGroupInfo) -> BoundariesListLogicalGroup {
584    BoundariesListLogicalGroup {
585        name: group.name.clone(),
586        children: group.children.clone(),
587        auto_discover: group.auto_discover.clone(),
588        status: group.status,
589        source_zone_index: group.source_zone_index,
590        file_count: group.file_count,
591        authored_rule: group.authored_rule.clone(),
592        fallback_zone: group.fallback_zone.clone(),
593        merged_from: group.merged_from.clone(),
594        original_zone_root: group.original_zone_root.clone(),
595        child_source_indices: group.child_source_indices.clone(),
596    }
597}
598
599#[cfg(test)]
600mod tests {
601    use std::process::Command;
602
603    use serde_json::json;
604
605    use super::*;
606
607    fn empty_boundary_data() -> BoundaryData {
608        BoundaryData {
609            zones: vec![],
610            rules: vec![],
611            logical_groups: vec![],
612            is_empty: true,
613        }
614    }
615
616    fn boundary_data_to_json(data: &BoundaryData) -> serde_json::Value {
617        serde_json::to_value(boundary_data_to_output(data))
618            .expect("boundary list output should serialize")
619    }
620
621    fn git(project: &Path, args: &[&str]) {
622        let status = Command::new("git")
623            .args(args)
624            .current_dir(project)
625            .status()
626            .expect("git command should run");
627        assert!(status.success(), "git {args:?} failed");
628    }
629
630    fn setup_changed_boundary_project() -> tempfile::TempDir {
631        let project = tempfile::tempdir().expect("project");
632        std::fs::write(
633            project.path().join("package.json"),
634            r#"{"name":"changed-list-api","main":"src/app/index.ts"}"#,
635        )
636        .expect("write package");
637        std::fs::write(
638            project.path().join(".fallowrc.json"),
639            r#"{
640                "boundaries": {
641                    "zones": [
642                        { "name": "app", "patterns": ["src/app/**"] },
643                        { "name": "shared", "patterns": ["src/shared/**"] }
644                    ]
645                }
646            }"#,
647        )
648        .expect("write config");
649        std::fs::create_dir_all(project.path().join("src/app")).expect("create app");
650        std::fs::create_dir_all(project.path().join("src/shared")).expect("create shared");
651        std::fs::write(
652            project.path().join("src/app/index.ts"),
653            "export const app = 1;\n",
654        )
655        .expect("write app");
656        std::fs::write(
657            project.path().join("src/shared/index.ts"),
658            "export const shared = 1;\n",
659        )
660        .expect("write shared");
661
662        git(project.path(), &["init", "-q"]);
663        git(
664            project.path(),
665            &["config", "user.email", "test@example.com"],
666        );
667        git(project.path(), &["config", "user.name", "Test User"]);
668        git(project.path(), &["config", "commit.gpgsign", "false"]);
669        git(project.path(), &["add", "."]);
670        git(project.path(), &["commit", "-qm", "initial"]);
671        std::fs::write(
672            project.path().join("src/app/index.ts"),
673            "export const app = 2;\n",
674        )
675        .expect("modify app");
676        project
677    }
678
679    #[test]
680    fn project_info_default_sections_match_plain_list_contract() {
681        let project = tempfile::tempdir().expect("project");
682        std::fs::write(
683            project.path().join("package.json"),
684            r#"{"name":"project-info-api","main":"src/index.ts"}"#,
685        )
686        .expect("write package");
687        std::fs::create_dir_all(project.path().join("src")).expect("create src");
688        std::fs::write(
689            project.path().join("src/index.ts"),
690            "export const value = 1;\n",
691        )
692        .expect("write source");
693
694        let output = serialize_project_info_programmatic_json(
695            run_project_info(&ProjectInfoOptions {
696                analysis: AnalysisOptions {
697                    root: Some(project.path().to_path_buf()),
698                    no_cache: true,
699                    ..AnalysisOptions::default()
700                },
701                ..ProjectInfoOptions::default()
702            })
703            .expect("project info should run"),
704        )
705        .expect("project info should serialize");
706
707        assert_eq!(output["file_count"], 1);
708        assert_eq!(output["files"][0], "src/index.ts");
709        assert_eq!(output["entry_point_count"], 1);
710        assert_eq!(output["workspace_count"], 0);
711        assert!(output.get("kind").is_none());
712    }
713
714    #[test]
715    fn project_info_surfaces_malformed_root_package_json() {
716        let project = tempfile::tempdir().expect("project");
717        std::fs::write(project.path().join("package.json"), "{").expect("write package");
718
719        let err = run_project_info(&ProjectInfoOptions {
720            analysis: AnalysisOptions {
721                root: Some(project.path().to_path_buf()),
722                no_cache: true,
723                ..AnalysisOptions::default()
724            },
725            ..ProjectInfoOptions::default()
726        })
727        .expect_err("malformed root package.json must fail project info");
728
729        assert_eq!(err.exit_code, 2);
730        assert_eq!(err.code.as_deref(), Some("FALLOW_CONFIG_LOAD_FAILED"));
731        assert!(
732            err.message.contains("package.json"),
733            "error should name the malformed root package.json"
734        );
735    }
736
737    #[test]
738    fn project_info_default_sections_include_undeclared_workspace_diagnostic() {
739        let project = tempfile::tempdir().expect("project");
740        std::fs::write(
741            project.path().join("package.json"),
742            r#"{"name":"project-info-api","workspaces":["packages/*"]}"#,
743        )
744        .expect("write package");
745        std::fs::create_dir_all(project.path().join("packages/app")).expect("workspace dir");
746        std::fs::write(
747            project.path().join("packages/app/package.json"),
748            r#"{"name":"app","main":"src/index.ts"}"#,
749        )
750        .expect("write workspace package");
751        std::fs::create_dir_all(project.path().join("tools/extra")).expect("extra package dir");
752        std::fs::write(
753            project.path().join("tools/extra/package.json"),
754            r#"{"name":"extra"}"#,
755        )
756        .expect("write extra package");
757
758        let output = serialize_project_info_programmatic_json(
759            run_project_info(&ProjectInfoOptions {
760                analysis: AnalysisOptions {
761                    root: Some(project.path().to_path_buf()),
762                    no_cache: true,
763                    ..AnalysisOptions::default()
764                },
765                ..ProjectInfoOptions::default()
766            })
767            .expect("project info should run"),
768        )
769        .expect("project info should serialize");
770
771        let diagnostics = output["workspace_diagnostics"]
772            .as_array()
773            .expect("project info should include workspace_diagnostics");
774        assert!(
775            diagnostics.iter().any(|diagnostic| {
776                diagnostic["kind"].as_str() == Some("undeclared-workspace")
777                    && diagnostic["path"]
778                        .as_str()
779                        .is_some_and(|path| path.replace('\\', "/").ends_with("/tools/extra"))
780            }),
781            "project info must include undeclared workspace diagnostics from the reused session, got {diagnostics:#?}"
782        );
783    }
784
785    #[test]
786    fn list_runtimes_scope_files_and_boundary_counts_to_changed_since() {
787        let project = setup_changed_boundary_project();
788        let analysis = AnalysisOptions {
789            root: Some(project.path().to_path_buf()),
790            changed_since: Some("HEAD".to_string()),
791            no_cache: true,
792            ..AnalysisOptions::default()
793        };
794
795        let project_info = serialize_project_info_programmatic_json(
796            run_project_info(&ProjectInfoOptions {
797                analysis: analysis.clone(),
798                files: true,
799                boundaries: true,
800                ..ProjectInfoOptions::default()
801            })
802            .expect("project info should run"),
803        )
804        .expect("project info should serialize");
805        let files = project_info["files"].as_array().expect("files array");
806        assert_eq!(files, &[json!("src/app/index.ts")]);
807        assert_eq!(project_info["boundaries"]["zones"][0]["file_count"], 1);
808        assert_eq!(project_info["boundaries"]["zones"][1]["file_count"], 0);
809
810        let boundaries = serialize_list_boundaries_programmatic_json(
811            run_list_boundaries(&ListBoundariesOptions { analysis })
812                .expect("list boundaries should run"),
813        )
814        .expect("list boundaries should serialize");
815        assert_eq!(boundaries["boundaries"]["zones"][0]["file_count"], 1);
816        assert_eq!(boundaries["boundaries"]["zones"][1]["file_count"], 0);
817    }
818
819    #[test]
820    fn boundary_json_empty_includes_logical_groups_key() {
821        let value = boundary_data_to_json(&empty_boundary_data());
822
823        assert_eq!(value["configured"], false);
824        assert_eq!(value["zone_count"], 0);
825        assert_eq!(value["rule_count"], 0);
826        assert_eq!(value["logical_group_count"], 0);
827        assert_eq!(value["logical_groups"], json!([]));
828    }
829
830    #[test]
831    fn boundary_json_logical_group_carries_all_fields() {
832        let data = BoundaryData {
833            zones: vec![ZoneInfo {
834                name: "features/auth".to_string(),
835                patterns: vec!["src/features/auth/**".to_string()],
836                file_count: 3,
837            }],
838            rules: vec![],
839            logical_groups: vec![LogicalGroupInfo {
840                name: "features".to_string(),
841                children: vec!["features/auth".to_string()],
842                auto_discover: vec!["./src/features/".to_string()],
843                authored_rule: Some(AuthoredRule {
844                    allow: vec!["shared".to_string()],
845                    allow_type_only: vec!["types".to_string()],
846                }),
847                fallback_zone: None,
848                source_zone_index: 1,
849                status: LogicalGroupStatus::Ok,
850                file_count: 3,
851                child_file_count: 3,
852                fallback_file_count: 0,
853                merged_from: None,
854                original_zone_root: None,
855                child_source_indices: vec![],
856            }],
857            is_empty: false,
858        };
859
860        let value = boundary_data_to_json(&data);
861        let group = &value["logical_groups"][0];
862
863        assert_eq!(value["logical_group_count"], 1);
864        assert_eq!(group["name"], "features");
865        assert_eq!(group["children"][0], "features/auth");
866        assert_eq!(group["auto_discover"][0], "./src/features/");
867        assert_eq!(group["status"], "ok");
868        assert_eq!(group["source_zone_index"], 1);
869        assert_eq!(group["file_count"], 3);
870        assert_eq!(group["authored_rule"]["allow"][0], "shared");
871        assert_eq!(group["authored_rule"]["allow_type_only"][0], "types");
872        assert!(group.get("fallback_zone").is_none());
873        assert!(group.get("merged_from").is_none());
874        assert!(group.get("original_zone_root").is_none());
875        assert!(group.get("child_source_indices").is_none());
876    }
877
878    #[test]
879    fn boundary_json_logical_group_optional_fields_round_trip() {
880        let data = BoundaryData {
881            zones: vec![],
882            rules: vec![],
883            logical_groups: vec![LogicalGroupInfo {
884                name: "features".to_string(),
885                children: vec!["features/auth".to_string(), "features/billing".to_string()],
886                auto_discover: vec!["src/features".to_string(), "src/modules".to_string()],
887                authored_rule: None,
888                fallback_zone: Some("features".to_string()),
889                source_zone_index: 0,
890                status: LogicalGroupStatus::Empty,
891                file_count: 2,
892                child_file_count: 0,
893                fallback_file_count: 2,
894                merged_from: Some(vec![0, 3]),
895                original_zone_root: Some("packages/app/".to_string()),
896                child_source_indices: vec![0, 1],
897            }],
898            is_empty: false,
899        };
900
901        let group = &boundary_data_to_json(&data)["logical_groups"][0];
902
903        assert_eq!(group["status"], "empty");
904        assert_eq!(group["fallback_zone"], "features");
905        assert_eq!(group["merged_from"][1], 3);
906        assert_eq!(group["original_zone_root"], "packages/app/");
907        assert_eq!(group["child_source_indices"][1], 1);
908    }
909}