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