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,
26 pub entry_points: bool,
28 pub files: bool,
30 pub plugins: bool,
32 pub boundaries: bool,
34}
35
36#[derive(Debug, Clone, Default)]
38pub struct ListBoundariesOptions {
39 pub analysis: AnalysisOptions,
41}
42
43#[derive(Debug, Clone)]
45pub struct ProjectInfoProgrammaticOutput {
46 pub plugins: Option<Vec<String>>,
48 pub files: Option<Vec<String>>,
50 pub entry_points: Option<Vec<ListEntryPointOutput>>,
52 pub boundaries: Option<BoundariesListing>,
54 pub workspaces: Option<WorkspacesOutput<fallow_config::WorkspaceDiagnostic>>,
56 pub envelope: ListJsonEnvelope,
58 pub envelope_mode: RootEnvelopeMode,
60}
61
62pub fn serialize_project_info_programmatic_json(
68 output: ProjectInfoProgrammaticOutput,
69) -> ProgrammaticResult<serde_json::Value> {
70 serialize_list_json_output(
71 ListJsonOutputInput {
72 plugins: output.plugins,
73 files: output.files,
74 entry_points: output.entry_points,
75 boundaries: output.boundaries,
76 workspaces: output.workspaces,
77 },
78 output.envelope_mode,
79 output.envelope,
80 )
81 .map_err(|err| {
82 ProgrammaticError::new(format!("failed to serialize project info output: {err}"), 2)
83 .with_code("FALLOW_PROJECT_INFO_SERIALIZE_FAILED")
84 .with_context("project_info")
85 })
86}
87
88#[derive(Debug, Clone)]
90pub struct ListBoundariesProgrammaticOutput {
91 pub boundaries: BoundariesListing,
93 pub envelope_mode: RootEnvelopeMode,
95}
96
97pub fn serialize_list_boundaries_programmatic_json(
103 output: ListBoundariesProgrammaticOutput,
104) -> ProgrammaticResult<serde_json::Value> {
105 serialize_list_json_output(
106 ListJsonOutputInput::<BoundariesListing, serde_json::Value> {
107 plugins: None,
108 files: None,
109 entry_points: None,
110 boundaries: Some(output.boundaries),
111 workspaces: None,
112 },
113 output.envelope_mode,
114 ListJsonEnvelope::Boundaries,
115 )
116 .map_err(|err| {
117 ProgrammaticError::new(
118 format!("failed to serialize list boundaries output: {err}"),
119 2,
120 )
121 .with_code("FALLOW_LIST_BOUNDARIES_SERIALIZE_FAILED")
122 .with_context("list_boundaries")
123 })
124}
125
126#[derive(Debug, Clone)]
128pub struct BoundaryData {
129 pub zones: Vec<ZoneInfo>,
131 pub rules: Vec<RuleInfo>,
133 pub logical_groups: Vec<LogicalGroupInfo>,
135 pub is_empty: bool,
137}
138
139#[derive(Debug, Clone)]
141pub struct ZoneInfo {
142 pub name: String,
144 pub patterns: Vec<String>,
146 pub file_count: usize,
148}
149
150#[derive(Debug, Clone)]
152pub struct RuleInfo {
153 pub from: String,
155 pub allow: Vec<String>,
157}
158
159#[derive(Debug, Clone)]
161pub struct LogicalGroupInfo {
162 pub name: String,
164 pub children: Vec<String>,
166 pub auto_discover: Vec<String>,
168 pub authored_rule: Option<AuthoredRule>,
170 pub fallback_zone: Option<String>,
172 pub source_zone_index: usize,
174 pub status: LogicalGroupStatus,
176 pub file_count: usize,
178 pub child_file_count: usize,
180 pub fallback_file_count: usize,
182 pub merged_from: Option<Vec<usize>>,
184 pub original_zone_root: Option<String>,
186 pub child_source_indices: Vec<usize>,
188}
189
190pub fn run_list_boundaries(
200 options: &ListBoundariesOptions,
201) -> ProgrammaticResult<ListBoundariesProgrammaticOutput> {
202 let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
203 resolved.install(|| {
204 resolved.ensure_not_cancelled("config load and file discovery")?;
205 let project_config = load_list_project_config(&resolved)?;
206 let session = fallow_engine::session::AnalysisSession::from_config(project_config);
207 resolved.ensure_not_cancelled("the boundary listing")?;
208 let changed_files = changed_files_for_run(&resolved)?;
209 let discovered = scoped_discovered_files(session.files(), changed_files.as_ref());
210 let data = compute_boundary_data(session.config(), Some(&discovered));
211
212 Ok(ListBoundariesProgrammaticOutput {
213 boundaries: boundary_data_to_output(&data),
214 envelope_mode: RootEnvelopeMode::Tagged,
215 })
216 })
217}
218
219pub fn run_project_info(
229 options: &ProjectInfoOptions,
230) -> ProgrammaticResult<ProjectInfoProgrammaticOutput> {
231 let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
232 resolved.install(|| {
233 resolved.ensure_not_cancelled("config load and file discovery")?;
234 let project_config = load_list_project_config(&resolved)?;
235 let session = fallow_engine::session::AnalysisSession::from_config(project_config);
236 resolved.ensure_not_cancelled("the project listing")?;
237 let config = session.config();
238 let workspaces = session.workspaces();
239 let show_all = project_info_should_show_all(options);
240 let changed_files = changed_files_for_run(&resolved)?;
241 let discovered =
242 project_info_discovered_files(options, show_all, &session, changed_files.as_ref());
243 let discovered_ref = discovered.as_deref();
244
245 let plugin_result = collect_plugin_result(
246 resolved.root(),
247 config,
248 options,
249 show_all,
250 discovered_ref,
251 workspaces,
252 )?;
253 let entry_points = collect_entry_points(
254 config,
255 options,
256 show_all,
257 discovered_ref,
258 workspaces,
259 plugin_result.as_ref(),
260 );
261 let boundaries = options
262 .boundaries
263 .then(|| boundary_data_to_output(&compute_boundary_data(config, discovered_ref)));
264 let workspaces = if show_all {
265 Some(collect_workspace_output(
266 resolved.root(),
267 workspaces,
268 session.workspace_diagnostics(),
269 ))
270 } else {
271 None
272 };
273 let envelope = if boundaries.is_some() {
274 ListJsonEnvelope::Boundaries
275 } else {
276 ListJsonEnvelope::Plain
277 };
278
279 Ok(ProjectInfoProgrammaticOutput {
280 plugins: collect_plugins(options, show_all, plugin_result.as_ref()),
281 files: collect_files(options, show_all, discovered_ref, resolved.root()),
282 entry_points: entry_points
283 .map(|entries| entry_points_to_output(&entries, resolved.root())),
284 boundaries,
285 workspaces,
286 envelope,
287 envelope_mode: RootEnvelopeMode::Tagged,
288 })
289 })
290}
291
292fn project_info_discovered_files(
293 options: &ProjectInfoOptions,
294 show_all: bool,
295 session: &fallow_engine::session::AnalysisSession,
296 changed_files: Option<&rustc_hash::FxHashSet<PathBuf>>,
297) -> Option<Vec<DiscoveredFile>> {
298 let needs_discovery =
299 options.files || options.entry_points || options.boundaries || options.plugins || show_all;
300 needs_discovery.then(|| scoped_discovered_files(session.files(), changed_files))
301}
302
303fn scoped_discovered_files(
304 files: &[DiscoveredFile],
305 changed_files: Option<&rustc_hash::FxHashSet<PathBuf>>,
306) -> Vec<DiscoveredFile> {
307 let Some(changed_files) = changed_files else {
308 return files.to_vec();
309 };
310 files
311 .iter()
312 .filter(|file| changed_files.contains(&file.path))
313 .cloned()
314 .collect()
315}
316
317fn load_list_project_config(
318 resolved: &crate::ProgrammaticAnalysisContext,
319) -> ProgrammaticResult<fallow_engine::project_config::ProjectConfig> {
320 fallow_engine::project_config::config_for_project_analysis(
321 resolved.root(),
322 resolved.config_path().as_deref(),
323 fallow_engine::project_config::ProjectConfigOptions {
324 output: fallow_types::output_format::OutputFormat::Json,
325 no_cache: resolved.no_cache(),
326 threads: resolved.threads(),
327 production_override: resolved.production_override(),
328 quiet: true,
329 analysis: fallow_config::ProductionAnalysis::DeadCode,
330 allow_remote_extends: resolved.allow_remote_extends(),
331 },
332 )
333 .map_err(|err| {
334 ProgrammaticError::new(format!("failed to load config: {err}"), 2)
335 .with_code("FALLOW_CONFIG_LOAD_FAILED")
336 .with_context("analysis.configPath")
337 })
338}
339
340const fn project_info_should_show_all(options: &ProjectInfoOptions) -> bool {
341 !options.entry_points && !options.files && !options.plugins && !options.boundaries
342}
343
344fn collect_plugins(
345 options: &ProjectInfoOptions,
346 show_all: bool,
347 plugin_result: Option<&fallow_engine::plugins::AggregatedPluginResult>,
348) -> Option<Vec<String>> {
349 if options.plugins || show_all {
350 plugin_result.map(|plugin_result| plugin_result.active_plugins().to_vec())
351 } else {
352 None
353 }
354}
355
356fn collect_files(
357 options: &ProjectInfoOptions,
358 show_all: bool,
359 discovered: Option<&[DiscoveredFile]>,
360 root: &Path,
361) -> Option<Vec<String>> {
362 if options.files || show_all {
363 discovered.map(|files| {
364 files
365 .iter()
366 .map(|file| format_display_path(&file.path, root))
367 .collect()
368 })
369 } else {
370 None
371 }
372}
373
374fn collect_plugin_result(
375 root: &Path,
376 config: &fallow_config::ResolvedConfig,
377 options: &ProjectInfoOptions,
378 show_all: bool,
379 discovered: Option<&[DiscoveredFile]>,
380 workspaces: &[fallow_config::WorkspaceInfo],
381) -> ProgrammaticResult<Option<fallow_engine::plugins::AggregatedPluginResult>> {
382 if !(options.plugins || options.entry_points || show_all) {
383 return Ok(None);
384 }
385 let Some(files) = discovered else {
386 return Ok(None);
387 };
388 fallow_engine::list_inventory::collect_active_plugins(root, config, files, workspaces)
389 .map(Some)
390 .map_err(|err| match err {
391 fallow_engine::list_inventory::ListInventoryError::PluginRegex(errors) => {
392 ProgrammaticError::new(
393 fallow_engine::plugins::registry::format_plugin_regex_errors(&errors),
394 2,
395 )
396 .with_code("FALLOW_PLUGIN_REGEX_FAILED")
397 .with_context("project_info.plugins")
398 }
399 })
400}
401
402fn collect_entry_points(
403 config: &fallow_config::ResolvedConfig,
404 options: &ProjectInfoOptions,
405 show_all: bool,
406 discovered: Option<&[DiscoveredFile]>,
407 workspaces: &[fallow_config::WorkspaceInfo],
408 plugin_result: Option<&fallow_engine::plugins::AggregatedPluginResult>,
409) -> Option<Vec<EntryPoint>> {
410 if !(options.entry_points || show_all) {
411 return None;
412 }
413 let discovered = discovered?;
414 Some(fallow_engine::list_inventory::collect_entry_points(
415 config,
416 discovered,
417 workspaces,
418 plugin_result,
419 ))
420}
421
422fn entry_points_to_output(entries: &[EntryPoint], root: &Path) -> Vec<ListEntryPointOutput> {
423 entries
424 .iter()
425 .map(|entry| ListEntryPointOutput {
426 path: format_display_path(&entry.path, root),
427 source: entry.source.to_string(),
428 })
429 .collect()
430}
431
432fn collect_workspace_output(
433 root: &Path,
434 workspaces: &[fallow_config::WorkspaceInfo],
435 diagnostics: &[fallow_config::WorkspaceDiagnostic],
436) -> WorkspacesOutput<fallow_config::WorkspaceDiagnostic> {
437 let workspaces = workspaces
438 .iter()
439 .map(|workspace| {
440 let relative = workspace.root.strip_prefix(root).unwrap_or(&workspace.root);
441 WorkspaceOutputInfo {
442 name: workspace.name.clone(),
443 path: relative.display().to_string().replace('\\', "/"),
444 is_internal_dependency: workspace.is_internal_dependency,
445 }
446 })
447 .collect::<Vec<_>>();
448 WorkspacesOutput {
449 workspace_count: workspaces.len(),
450 workspaces,
451 workspace_diagnostics: diagnostics
455 .iter()
456 .map(|diagnostic| diagnostic.clone().into_root_relative(root))
457 .collect(),
458 }
459}
460
461fn format_display_path(path: &Path, root: &Path) -> String {
462 path.strip_prefix(root)
463 .unwrap_or(path)
464 .display()
465 .to_string()
466 .replace('\\', "/")
467}
468
469#[must_use]
471pub fn compute_boundary_data(
472 config: &fallow_config::ResolvedConfig,
473 discovered: Option<&[DiscoveredFile]>,
474) -> BoundaryData {
475 let boundaries = &config.boundaries;
476
477 if boundaries.is_empty() {
478 return BoundaryData {
479 zones: vec![],
480 rules: vec![],
481 logical_groups: vec![],
482 is_empty: true,
483 };
484 }
485
486 let zones = build_boundary_zones(config, discovered);
487 let rules = build_boundary_rules(boundaries);
488 let logical_groups = build_logical_groups(boundaries, &zones);
489
490 BoundaryData {
491 zones,
492 rules,
493 logical_groups,
494 is_empty: false,
495 }
496}
497
498fn build_boundary_zones(
499 config: &fallow_config::ResolvedConfig,
500 discovered: Option<&[DiscoveredFile]>,
501) -> Vec<ZoneInfo> {
502 config
503 .boundaries
504 .zones
505 .iter()
506 .map(|zone| ZoneInfo {
507 name: zone.name.clone(),
508 patterns: zone.matchers.iter().map(|m| m.glob().to_string()).collect(),
509 file_count: count_boundary_zone_files(config, discovered, &zone.name),
510 })
511 .collect()
512}
513
514fn count_boundary_zone_files(
515 config: &fallow_config::ResolvedConfig,
516 discovered: Option<&[DiscoveredFile]>,
517 zone_name: &str,
518) -> usize {
519 discovered.map_or(0, |files| {
520 files
521 .iter()
522 .filter(|file| {
523 let rel = file
524 .path
525 .strip_prefix(&config.root)
526 .ok()
527 .map(|path| path.to_string_lossy().replace('\\', "/"));
528 rel.is_some_and(|path| config.boundaries.classify_zone(&path) == Some(zone_name))
529 })
530 .count()
531 })
532}
533
534fn build_boundary_rules(boundaries: &ResolvedBoundaryConfig) -> Vec<RuleInfo> {
535 boundaries
536 .rules
537 .iter()
538 .map(|rule| RuleInfo {
539 from: rule.from_zone.clone(),
540 allow: rule.allowed_zones.clone(),
541 })
542 .collect()
543}
544
545fn build_logical_groups(
546 boundaries: &ResolvedBoundaryConfig,
547 zones: &[ZoneInfo],
548) -> Vec<LogicalGroupInfo> {
549 let zone_count_by_name: FxHashMap<&str, usize> = zones
550 .iter()
551 .map(|zone| (zone.name.as_str(), zone.file_count))
552 .collect();
553
554 boundaries
555 .logical_groups
556 .iter()
557 .map(|group| logical_group_info(group, &zone_count_by_name))
558 .collect()
559}
560
561fn logical_group_info(
562 group: &LogicalGroup,
563 zone_count_by_name: &FxHashMap<&str, usize>,
564) -> LogicalGroupInfo {
565 let child_file_count: usize = group
566 .children
567 .iter()
568 .filter_map(|child| zone_count_by_name.get(child.as_str()).copied())
569 .sum();
570 let fallback_file_count = group
571 .fallback_zone
572 .as_deref()
573 .and_then(|fallback| zone_count_by_name.get(fallback).copied())
574 .unwrap_or(0);
575
576 LogicalGroupInfo {
577 name: group.name.clone(),
578 children: group.children.clone(),
579 auto_discover: group.auto_discover.clone(),
580 authored_rule: group.authored_rule.clone(),
581 fallback_zone: group.fallback_zone.clone(),
582 source_zone_index: group.source_zone_index,
583 status: group.status,
584 file_count: child_file_count + fallback_file_count,
585 child_file_count,
586 fallback_file_count,
587 merged_from: group.merged_from.clone(),
588 original_zone_root: group.original_zone_root.clone(),
589 child_source_indices: group.child_source_indices.clone(),
590 }
591}
592
593#[must_use]
595pub fn boundary_data_to_output(data: &BoundaryData) -> BoundariesListing {
596 if data.is_empty {
597 return BoundariesListing {
598 configured: false,
599 zone_count: 0,
600 zones: Vec::new(),
601 rule_count: 0,
602 rules: Vec::new(),
603 logical_group_count: 0,
604 logical_groups: Vec::new(),
605 };
606 }
607
608 BoundariesListing {
609 configured: true,
610 zone_count: data.zones.len(),
611 zones: data
612 .zones
613 .iter()
614 .map(|zone| BoundariesListZone {
615 name: zone.name.clone(),
616 patterns: zone.patterns.clone(),
617 file_count: zone.file_count,
618 })
619 .collect(),
620 rule_count: data.rules.len(),
621 rules: data
622 .rules
623 .iter()
624 .map(|rule| BoundariesListRule {
625 from: rule.from.clone(),
626 allow: rule.allow.clone(),
627 })
628 .collect(),
629 logical_group_count: data.logical_groups.len(),
630 logical_groups: data
631 .logical_groups
632 .iter()
633 .map(logical_group_info_to_output)
634 .collect(),
635 }
636}
637
638fn logical_group_info_to_output(group: &LogicalGroupInfo) -> BoundariesListLogicalGroup {
639 BoundariesListLogicalGroup {
640 name: group.name.clone(),
641 children: group.children.clone(),
642 auto_discover: group.auto_discover.clone(),
643 status: group.status,
644 source_zone_index: group.source_zone_index,
645 file_count: group.file_count,
646 authored_rule: group.authored_rule.clone(),
647 fallback_zone: group.fallback_zone.clone(),
648 merged_from: group.merged_from.clone(),
649 original_zone_root: group.original_zone_root.clone(),
650 child_source_indices: group.child_source_indices.clone(),
651 }
652}
653
654#[cfg(test)]
655mod tests {
656 use std::process::Command;
657
658 use serde_json::json;
659
660 use super::*;
661
662 #[test]
667 fn workspace_output_emits_project_relative_diagnostic_paths() {
668 let root = Path::new("/project");
669 let output = collect_workspace_output(
670 root,
671 &[],
672 &[fallow_config::WorkspaceDiagnostic::new(
673 root,
674 root.join("packages/inner"),
675 fallow_config::WorkspaceDiagnosticKind::UndeclaredWorkspace,
676 )],
677 );
678
679 let value = serde_json::to_value(&output).expect("workspaces output serializes");
680 assert_eq!(value["workspace_diagnostics"][0]["path"], "packages/inner");
681 }
682
683 fn empty_boundary_data() -> BoundaryData {
684 BoundaryData {
685 zones: vec![],
686 rules: vec![],
687 logical_groups: vec![],
688 is_empty: true,
689 }
690 }
691
692 fn boundary_data_to_json(data: &BoundaryData) -> serde_json::Value {
693 serde_json::to_value(boundary_data_to_output(data))
694 .expect("boundary list output should serialize")
695 }
696
697 fn git(project: &Path, args: &[&str]) {
698 let status = Command::new("git")
699 .args(args)
700 .current_dir(project)
701 .status()
702 .expect("git command should run");
703 assert!(status.success(), "git {args:?} failed");
704 }
705
706 fn setup_changed_boundary_project() -> tempfile::TempDir {
707 let project = tempfile::tempdir().expect("project");
708 std::fs::write(
709 project.path().join("package.json"),
710 r#"{"name":"changed-list-api","main":"src/app/index.ts"}"#,
711 )
712 .expect("write package");
713 std::fs::write(
714 project.path().join(".fallowrc.json"),
715 r#"{
716 "boundaries": {
717 "zones": [
718 { "name": "app", "patterns": ["src/app/**"] },
719 { "name": "shared", "patterns": ["src/shared/**"] }
720 ]
721 }
722 }"#,
723 )
724 .expect("write config");
725 std::fs::create_dir_all(project.path().join("src/app")).expect("create app");
726 std::fs::create_dir_all(project.path().join("src/shared")).expect("create shared");
727 std::fs::write(
728 project.path().join("src/app/index.ts"),
729 "export const app = 1;\n",
730 )
731 .expect("write app");
732 std::fs::write(
733 project.path().join("src/shared/index.ts"),
734 "export const shared = 1;\n",
735 )
736 .expect("write shared");
737
738 git(project.path(), &["init", "-q"]);
739 git(
740 project.path(),
741 &["config", "user.email", "test@example.com"],
742 );
743 git(project.path(), &["config", "user.name", "Test User"]);
744 git(project.path(), &["config", "commit.gpgsign", "false"]);
745 git(project.path(), &["add", "."]);
746 git(project.path(), &["commit", "-qm", "initial"]);
747 std::fs::write(
748 project.path().join("src/app/index.ts"),
749 "export const app = 2;\n",
750 )
751 .expect("modify app");
752 project
753 }
754
755 #[test]
756 fn project_info_default_sections_match_plain_list_contract() {
757 let project = tempfile::tempdir().expect("project");
758 std::fs::write(
759 project.path().join("package.json"),
760 r#"{"name":"project-info-api","main":"src/index.ts"}"#,
761 )
762 .expect("write package");
763 std::fs::create_dir_all(project.path().join("src")).expect("create src");
764 std::fs::write(
765 project.path().join("src/index.ts"),
766 "export const value = 1;\n",
767 )
768 .expect("write source");
769
770 let output = serialize_project_info_programmatic_json(
771 run_project_info(&ProjectInfoOptions {
772 analysis: AnalysisOptions {
773 root: Some(project.path().to_path_buf()),
774 no_cache: true,
775 ..AnalysisOptions::default()
776 },
777 ..ProjectInfoOptions::default()
778 })
779 .expect("project info should run"),
780 )
781 .expect("project info should serialize");
782
783 assert_eq!(output["file_count"], 1);
784 assert_eq!(output["files"][0], "src/index.ts");
785 assert_eq!(output["entry_point_count"], 1);
786 assert_eq!(output["workspace_count"], 0);
787 assert!(output.get("kind").is_none());
788 }
789
790 #[test]
791 fn project_info_surfaces_malformed_root_package_json() {
792 let project = tempfile::tempdir().expect("project");
793 std::fs::write(project.path().join("package.json"), "{").expect("write package");
794
795 let err = run_project_info(&ProjectInfoOptions {
796 analysis: AnalysisOptions {
797 root: Some(project.path().to_path_buf()),
798 no_cache: true,
799 ..AnalysisOptions::default()
800 },
801 ..ProjectInfoOptions::default()
802 })
803 .expect_err("malformed root package.json must fail project info");
804
805 assert_eq!(err.exit_code, 2);
806 assert_eq!(err.code.as_deref(), Some("FALLOW_CONFIG_LOAD_FAILED"));
807 assert!(
808 err.message.contains("package.json"),
809 "error should name the malformed root package.json"
810 );
811 }
812
813 #[test]
814 fn project_info_default_sections_include_undeclared_workspace_diagnostic() {
815 let project = tempfile::tempdir().expect("project");
816 std::fs::write(
817 project.path().join("package.json"),
818 r#"{"name":"project-info-api","workspaces":["packages/*"]}"#,
819 )
820 .expect("write package");
821 std::fs::create_dir_all(project.path().join("packages/app")).expect("workspace dir");
822 std::fs::write(
823 project.path().join("packages/app/package.json"),
824 r#"{"name":"app","main":"src/index.ts"}"#,
825 )
826 .expect("write workspace package");
827 std::fs::create_dir_all(project.path().join("tools/extra")).expect("extra package dir");
828 std::fs::write(
829 project.path().join("tools/extra/package.json"),
830 r#"{"name":"extra"}"#,
831 )
832 .expect("write extra package");
833
834 let output = serialize_project_info_programmatic_json(
835 run_project_info(&ProjectInfoOptions {
836 analysis: AnalysisOptions {
837 root: Some(project.path().to_path_buf()),
838 no_cache: true,
839 ..AnalysisOptions::default()
840 },
841 ..ProjectInfoOptions::default()
842 })
843 .expect("project info should run"),
844 )
845 .expect("project info should serialize");
846
847 let diagnostics = output["workspace_diagnostics"]
848 .as_array()
849 .expect("project info should include workspace_diagnostics");
850 assert!(
851 diagnostics.iter().any(|diagnostic| {
852 diagnostic["kind"].as_str() == Some("undeclared-workspace")
853 && diagnostic["path"].as_str() == Some("tools/extra")
856 }),
857 "project info must include undeclared workspace diagnostics from the reused session, got {diagnostics:#?}"
858 );
859 }
860
861 #[test]
866 fn project_info_reports_one_entry_per_directory_for_a_glob_in_two_manifests() {
867 let project = tempfile::tempdir().expect("project");
868 let root = project.path();
869 std::fs::create_dir_all(root.join("pkgs/aaa")).expect("first package-less dir");
870 std::fs::create_dir_all(root.join("pkgs/bbb")).expect("second package-less dir");
871 std::fs::create_dir_all(root.join("src")).expect("source dir");
872 std::fs::write(
873 root.join("package.json"),
874 r#"{"name":"two-manifest-root","private":true,"workspaces":["./pkgs/*"]}"#,
875 )
876 .expect("write root manifest");
877 std::fs::write(
878 root.join("pnpm-workspace.yaml"),
879 "packages:\n - \"pkgs/*\"\n",
880 )
881 .expect("write pnpm workspace manifest");
882 std::fs::write(root.join("src/index.ts"), "export const value = 1;\n")
883 .expect("write source");
884
885 let output = serialize_project_info_programmatic_json(
886 run_project_info(&ProjectInfoOptions {
887 analysis: AnalysisOptions {
888 root: Some(root.to_path_buf()),
889 no_cache: true,
890 ..AnalysisOptions::default()
891 },
892 ..ProjectInfoOptions::default()
893 })
894 .expect("project info should run"),
895 )
896 .expect("project info should serialize");
897
898 let reported: Vec<(&str, &str)> = output["workspace_diagnostics"]
899 .as_array()
900 .expect("project info should include workspace_diagnostics")
901 .iter()
902 .map(|diagnostic| {
903 (
904 diagnostic["pattern"].as_str().unwrap_or_default(),
905 diagnostic["path"].as_str().unwrap_or_default(),
906 )
907 })
908 .collect();
909
910 assert_eq!(
911 reported,
912 vec![("pkgs/*", "pkgs/aaa"), ("pkgs/*", "pkgs/bbb")],
913 "project_info agrees with the CLI envelopes: {}",
914 output["workspace_diagnostics"]
915 );
916 }
917
918 #[test]
919 fn list_runtimes_scope_files_and_boundary_counts_to_changed_since() {
920 let project = setup_changed_boundary_project();
921 let analysis = AnalysisOptions {
922 root: Some(project.path().to_path_buf()),
923 changed_since: Some("HEAD".to_string()),
924 no_cache: true,
925 ..AnalysisOptions::default()
926 };
927
928 let project_info = serialize_project_info_programmatic_json(
929 run_project_info(&ProjectInfoOptions {
930 analysis: analysis.clone(),
931 files: true,
932 boundaries: true,
933 ..ProjectInfoOptions::default()
934 })
935 .expect("project info should run"),
936 )
937 .expect("project info should serialize");
938 let files = project_info["files"].as_array().expect("files array");
939 assert_eq!(files, &[json!("src/app/index.ts")]);
940 assert_eq!(project_info["boundaries"]["zones"][0]["file_count"], 1);
941 assert_eq!(project_info["boundaries"]["zones"][1]["file_count"], 0);
942
943 let boundaries = serialize_list_boundaries_programmatic_json(
944 run_list_boundaries(&ListBoundariesOptions { analysis })
945 .expect("list boundaries should run"),
946 )
947 .expect("list boundaries should serialize");
948 assert_eq!(boundaries["boundaries"]["zones"][0]["file_count"], 1);
949 assert_eq!(boundaries["boundaries"]["zones"][1]["file_count"], 0);
950 }
951
952 #[test]
953 fn boundary_json_empty_includes_logical_groups_key() {
954 let value = boundary_data_to_json(&empty_boundary_data());
955
956 assert_eq!(value["configured"], false);
957 assert_eq!(value["zone_count"], 0);
958 assert_eq!(value["rule_count"], 0);
959 assert_eq!(value["logical_group_count"], 0);
960 assert_eq!(value["logical_groups"], json!([]));
961 }
962
963 #[test]
964 fn boundary_json_logical_group_carries_all_fields() {
965 let data = BoundaryData {
966 zones: vec![ZoneInfo {
967 name: "features/auth".to_string(),
968 patterns: vec!["src/features/auth/**".to_string()],
969 file_count: 3,
970 }],
971 rules: vec![],
972 logical_groups: vec![LogicalGroupInfo {
973 name: "features".to_string(),
974 children: vec!["features/auth".to_string()],
975 auto_discover: vec!["./src/features/".to_string()],
976 authored_rule: Some(AuthoredRule {
977 allow: vec!["shared".to_string()],
978 allow_type_only: vec!["types".to_string()],
979 }),
980 fallback_zone: None,
981 source_zone_index: 1,
982 status: LogicalGroupStatus::Ok,
983 file_count: 3,
984 child_file_count: 3,
985 fallback_file_count: 0,
986 merged_from: None,
987 original_zone_root: None,
988 child_source_indices: vec![],
989 }],
990 is_empty: false,
991 };
992
993 let value = boundary_data_to_json(&data);
994 let group = &value["logical_groups"][0];
995
996 assert_eq!(value["logical_group_count"], 1);
997 assert_eq!(group["name"], "features");
998 assert_eq!(group["children"][0], "features/auth");
999 assert_eq!(group["auto_discover"][0], "./src/features/");
1000 assert_eq!(group["status"], "ok");
1001 assert_eq!(group["source_zone_index"], 1);
1002 assert_eq!(group["file_count"], 3);
1003 assert_eq!(group["authored_rule"]["allow"][0], "shared");
1004 assert_eq!(group["authored_rule"]["allow_type_only"][0], "types");
1005 assert!(group.get("fallback_zone").is_none());
1006 assert!(group.get("merged_from").is_none());
1007 assert!(group.get("original_zone_root").is_none());
1008 assert!(group.get("child_source_indices").is_none());
1009 }
1010
1011 #[test]
1012 fn boundary_json_logical_group_optional_fields_round_trip() {
1013 let data = BoundaryData {
1014 zones: vec![],
1015 rules: vec![],
1016 logical_groups: vec![LogicalGroupInfo {
1017 name: "features".to_string(),
1018 children: vec!["features/auth".to_string(), "features/billing".to_string()],
1019 auto_discover: vec!["src/features".to_string(), "src/modules".to_string()],
1020 authored_rule: None,
1021 fallback_zone: Some("features".to_string()),
1022 source_zone_index: 0,
1023 status: LogicalGroupStatus::Empty,
1024 file_count: 2,
1025 child_file_count: 0,
1026 fallback_file_count: 2,
1027 merged_from: Some(vec![0, 3]),
1028 original_zone_root: Some("packages/app/".to_string()),
1029 child_source_indices: vec![0, 1],
1030 }],
1031 is_empty: false,
1032 };
1033
1034 let group = &boundary_data_to_json(&data)["logical_groups"][0];
1035
1036 assert_eq!(group["status"], "empty");
1037 assert_eq!(group["fallback_zone"], "features");
1038 assert_eq!(group["merged_from"][1], 3);
1039 assert_eq!(group["original_zone_root"], "packages/app/");
1040 assert_eq!(group["child_source_indices"][1], 1);
1041 }
1042}