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