Skip to main content

fallow_api/
list_runtime.rs

1//! Programmatic list-command runtime helpers.
2
3use 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/// Options for MCP/project metadata listing through the programmatic API.
22#[derive(Debug, Clone, Default)]
23pub struct ProjectInfoOptions {
24    /// Shared analysis options: root, config path, and cache behavior.
25    pub analysis: AnalysisOptions,
26    /// Include the resolved entry points in the output.
27    pub entry_points: bool,
28    /// Include the discovered file list in the output.
29    pub files: bool,
30    /// Include the detected plugin names in the output.
31    pub plugins: bool,
32    /// Include the boundaries listing in the output.
33    pub boundaries: bool,
34}
35
36/// Options for `fallow list --boundaries` through the programmatic API.
37#[derive(Debug, Clone, Default)]
38pub struct ListBoundariesOptions {
39    /// Shared analysis options: root, config path, and cache behavior.
40    pub analysis: AnalysisOptions,
41}
42
43/// Typed output for project metadata listing before JSON serialization.
44#[derive(Debug, Clone)]
45pub struct ProjectInfoProgrammaticOutput {
46    /// Detected plugin names; `None` when the section was not requested.
47    pub plugins: Option<Vec<String>>,
48    /// Discovered file paths; `None` when the section was not requested.
49    pub files: Option<Vec<String>>,
50    /// Resolved entry points; `None` when the section was not requested.
51    pub entry_points: Option<Vec<ListEntryPointOutput>>,
52    /// Boundaries listing; `None` when the section was not requested.
53    pub boundaries: Option<BoundariesListing>,
54    /// Workspace listing; `None` when the section was not requested.
55    pub workspaces: Option<WorkspacesOutput<fallow_config::WorkspaceDiagnostic>>,
56    /// Which list envelope shape wraps the serialized body.
57    pub envelope: ListJsonEnvelope,
58    /// Whether the serialized root envelope carries a `kind` discriminant.
59    pub envelope_mode: RootEnvelopeMode,
60}
61
62/// Serialize typed project-info output to the stable JSON contract.
63///
64/// # Errors
65///
66/// Returns a structured programmatic error when JSON serialization fails.
67pub 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/// Typed output for `fallow list --boundaries` before JSON serialization.
89#[derive(Debug, Clone)]
90pub struct ListBoundariesProgrammaticOutput {
91    /// Typed boundaries listing produced by the run.
92    pub boundaries: BoundariesListing,
93    /// Whether the serialized root envelope carries a `kind` discriminant.
94    pub envelope_mode: RootEnvelopeMode,
95}
96
97/// Serialize typed boundary-list output to the stable JSON contract.
98///
99/// # Errors
100///
101/// Returns a structured programmatic error when JSON serialization fails.
102pub 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/// Owned boundary listing data shared by CLI and programmatic renderers.
127#[derive(Debug, Clone)]
128pub struct BoundaryData {
129    /// Configured zones with per-zone matched file counts.
130    pub zones: Vec<ZoneInfo>,
131    /// Configured allow rules between zones.
132    pub rules: Vec<RuleInfo>,
133    /// Logical parent/child groups with derived file-count totals.
134    pub logical_groups: Vec<LogicalGroupInfo>,
135    /// Whether the resolved config declares no boundaries at all.
136    pub is_empty: bool,
137}
138
139/// One configured boundary zone with its matched file count.
140#[derive(Debug, Clone)]
141pub struct ZoneInfo {
142    /// Zone name from the config.
143    pub name: String,
144    /// Glob patterns that assign files to the zone.
145    pub patterns: Vec<String>,
146    /// Number of discovered files matching the zone.
147    pub file_count: usize,
148}
149
150/// One configured boundary allow rule.
151#[derive(Debug, Clone)]
152pub struct RuleInfo {
153    /// Source zone the rule applies to.
154    pub from: String,
155    /// Zone names the source zone may import from.
156    pub allow: Vec<String>,
157}
158
159/// View-model mirror of [`LogicalGroup`] with derived file-count totals.
160#[derive(Debug, Clone)]
161pub struct LogicalGroupInfo {
162    /// Parent zone name.
163    pub name: String,
164    /// Child zone names.
165    pub children: Vec<String>,
166    /// Authored `autoDiscover` paths.
167    pub auto_discover: Vec<String>,
168    /// Authored parent rule, if any.
169    pub authored_rule: Option<AuthoredRule>,
170    /// Fallback zone name, if the parent kept patterns.
171    pub fallback_zone: Option<String>,
172    /// Original `zones[]` index.
173    pub source_zone_index: usize,
174    /// Discovery status.
175    pub status: LogicalGroupStatus,
176    /// Total files across children plus the fallback zone.
177    pub file_count: usize,
178    /// Files matched by the child zones only.
179    pub child_file_count: usize,
180    /// Files matched by the fallback zone only.
181    pub fallback_file_count: usize,
182    /// Merged duplicate parent indices.
183    pub merged_from: Option<Vec<usize>>,
184    /// Authored parent root, if any.
185    pub original_zone_root: Option<String>,
186    /// Child-to-source zone indexes.
187    pub child_source_indices: Vec<usize>,
188}
189
190/// Run `list_boundaries` through the API-owned runtime path.
191///
192/// # Errors
193///
194/// Returns a structured programmatic error for invalid options or config-load
195/// failures, and `FALLOW_CANCELLED` when the caller's cancellation token is
196/// set. This route parses nothing: config load and file discovery are its only
197/// stage, so the token is observed on either side of that and nowhere within
198/// it.
199pub 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
219/// Run project metadata listing through the API-owned runtime path.
220///
221/// # Errors
222///
223/// Returns a structured programmatic error for invalid options, config-load
224/// failures, or plugin regex errors, and `FALLOW_CANCELLED` when the caller's
225/// cancellation token is set. This route parses nothing: config load and file
226/// discovery are its only stage, so the token is observed on either side of
227/// that and nowhere within it.
228pub 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        // Project-relative like the sibling `workspaces[].path` and like every
452        // analysis envelope's `workspace_diagnostics[]`. The list envelope has
453        // no post-serialization `strip_root_prefix` pass, so it normalises here.
454        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/// Compute boundary listing data from resolved config and optional discovery.
470#[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/// Convert boundary listing data to the stable output contract.
594#[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    /// The `fallow workspaces` / `fallow list --workspaces` envelope and the
663    /// MCP `project_info` tool have no post-serialization `strip_root_prefix`
664    /// pass, so `collect_workspace_output` is what makes their diagnostic
665    /// paths project-relative like every other envelope's.
666    #[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                    // Project-relative, like every other envelope's
854                    // `workspace_diagnostics[].path` (issue #2366 follow-up).
855                    && 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    /// Issue #2366: the MCP `project_info` tool reads the same workspace value
862    /// the `fallow workspaces` envelope does, so a glob declared in both
863    /// `package.json` and `pnpm-workspace.yaml` must reach an agent as one
864    /// entry per directory, project-relative, exactly as the CLI reports it.
865    #[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}