1use 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#[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#[derive(Debug, Clone, Default)]
30pub struct ListBoundariesOptions {
31 pub analysis: AnalysisOptions,
32}
33
34#[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
46pub 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#[derive(Debug, Clone)]
74pub struct ListBoundariesProgrammaticOutput {
75 pub boundaries: BoundariesListing,
76 pub envelope_mode: RootEnvelopeMode,
77}
78
79pub 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#[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#[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
148pub 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
171pub 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(®istry, &root.join("package.json"), root, &file_paths)?
312 .unwrap_or_default();
313 merge_workspace_plugins(root, ®istry, &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#[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#[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}