Skip to main content

fallow_api/
list_runtime.rs

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