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