1use 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#[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#[derive(Debug, Clone, Default)]
33pub struct ListBoundariesOptions {
34 pub analysis: AnalysisOptions,
35}
36
37#[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
49pub 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#[derive(Debug, Clone)]
77pub struct ListBoundariesProgrammaticOutput {
78 pub boundaries: BoundariesListing,
79 pub envelope_mode: RootEnvelopeMode,
80}
81
82pub 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#[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#[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
151pub 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
175pub 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#[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#[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}