Skip to main content

fallow_api/
lib.rs

1//! Programmatic API contract types for fallow.
2//!
3//! Runtime execution for dead-code and duplication lives here. Health output
4//! assembly is also API-owned, with the concrete runner injected while the
5//! remaining health pipeline moves out of the CLI crate. This crate owns the
6//! CLI-independent option, error, and output contracts so NAPI, future Rust
7//! embedders, and the engine facade can share them without depending on the
8//! CLI crate.
9#![cfg_attr(
10    test,
11    allow(
12        clippy::expect_used,
13        reason = "tests use expect to keep fixture setup concise"
14    )
15)]
16
17use std::path::PathBuf;
18
19use fallow_config::EmailMode;
20use fallow_output::EffortEstimate;
21use serde::Serialize;
22
23pub mod audit_output;
24pub mod combined_output;
25pub mod compact_output;
26pub mod dead_code_codeclimate;
27pub mod dead_code_sarif;
28pub mod dupes_output;
29pub mod editor;
30pub mod grouped_output;
31pub mod health_codeclimate;
32pub mod json_output;
33pub mod list_output;
34pub mod markdown_output;
35pub mod output_contracts;
36pub mod runtime;
37pub mod sarif_output;
38pub mod security_output;
39pub mod ci_output {
40    //! Compatibility re-exports for CI output builders now owned by
41    //! `fallow-output`.
42
43    pub use fallow_output::{
44        CiIssue, CiProvider, GroupedReviewIssues, MARKER_PREFIX_V2, MARKER_SUFFIX_V2,
45        MAX_COMMENT_BODY_BYTES, PROJECT_LEVEL_RULE_IDS, PrCommentRenderInput,
46        ReviewCommentRenderInput, ReviewEnvelopeRenderInput, ReviewEnvelopeRenderResult,
47        ReviewEnvelopeTruncation, ReviewGitlabDiffRefs, cap_body_with_marker, command_title,
48        composite_fingerprint, escape_md, github_check_conclusion,
49        group_review_issues_by_path_line, is_project_level_rule, issues_from_codeclimate,
50        issues_from_codeclimate_issues, render_pr_comment, render_review_comment_for_group,
51        render_review_envelope, review_label_from_codeclimate, summary_fingerprint, summary_label,
52    };
53}
54pub use audit_output::{
55    AuditAttribution, AuditCodeClimateOutputInput, AuditJsonHeaderInput, AuditJsonOutputInput,
56    AuditSarifOutputInput, AuditSummary, AuditVerdict, build_audit_codeclimate,
57    build_audit_codeclimate_issues, build_audit_header_json, build_audit_header_map,
58    build_audit_sarif, serialize_audit_json,
59};
60pub use ci_output::{
61    CiIssue, CiProvider, GroupedReviewIssues, MARKER_PREFIX_V2, MARKER_SUFFIX_V2,
62    MAX_COMMENT_BODY_BYTES, PROJECT_LEVEL_RULE_IDS, PrCommentRenderInput, ReviewCommentRenderInput,
63    ReviewEnvelopeRenderInput, ReviewEnvelopeRenderResult, ReviewEnvelopeTruncation,
64    ReviewGitlabDiffRefs, cap_body_with_marker, command_title, composite_fingerprint, escape_md,
65    github_check_conclusion, group_review_issues_by_path_line, is_project_level_rule,
66    issues_from_codeclimate, issues_from_codeclimate_issues, render_pr_comment,
67    render_review_comment_for_group, render_review_envelope, review_label_from_codeclimate,
68    summary_fingerprint, summary_label,
69};
70pub use combined_output::{
71    CombinedCheckJsonSection, CombinedJsonOutputInput, serialize_combined_dupes_json,
72    serialize_combined_health_json, serialize_combined_json,
73};
74pub use compact_output::{
75    build_compact_lines, build_duplication_compact_lines, build_grouped_compact_lines,
76    build_health_compact_lines,
77};
78pub use dead_code_codeclimate::build_codeclimate;
79pub use dead_code_sarif::build_sarif;
80pub use dupes_output::{
81    AttributedCloneGroup, AttributedCloneGroupFinding, AttributedInstance, CloneFamilyFinding,
82    CloneGroupFinding, DupesReportPayload, DuplicationGroup, DuplicationGrouping,
83    build_duplication_codeclimate,
84};
85pub use editor::{
86    ChangedFilesError, EditorAnalysisOutput, EditorAnalysisResults, EditorAnalysisSession,
87    EditorDeadCodeAnalysisOutput, EditorDuplicationReport, EditorInlineComplexityExceeded,
88    EditorInlineComplexityFinding, EditorProjectAnalysisOutput, collect_inline_complexity,
89    editor_duplicates, editor_extract, editor_results, editor_security, editor_suppress,
90    filter_inline_complexity_by_changed_files, resolve_git_toplevel,
91    try_get_changed_files_with_toplevel,
92};
93pub use grouped_output::{
94    ResultGroup, UNOWNED_GROUP_LABEL, build_duplication_grouping_with, group_analysis_results_with,
95    largest_clone_group_owner_with,
96};
97pub use health_codeclimate::build_health_codeclimate;
98pub use json_output::{
99    CheckJsonExtraOutputs, CheckJsonOutputInput, CheckJsonPayloadInput, DuplicationJsonOutputInput,
100    GroupedCheckJsonOutputInput, GroupedDuplicationJsonOutputInput,
101    harmonize_multi_kind_suppress_line_actions, serialize_check_json, serialize_check_json_payload,
102    serialize_duplication_json, serialize_grouped_check_json, serialize_grouped_duplication_json,
103};
104pub use list_output::{
105    ListJsonEnvelope, ListJsonOutputInput, build_list_json_output, serialize_list_json_output,
106};
107pub use markdown_output::{
108    build_duplication_markdown, build_grouped_markdown, build_health_markdown, build_markdown,
109};
110pub use output_contracts::{
111    AuditOutput, BoundariesListLogicalGroup, BoundariesListRule, BoundariesListZone,
112    BoundariesListing, CombinedOutput, FallowOutput, ListBoundariesOutput, ListEntryPointOutput,
113    ListOutput, ListPluginOutput, SecurityGate, SecurityOutput, SecurityOutputConfig,
114    SecuritySummaryOutput, WorkspacesOutput,
115};
116pub use runtime::{
117    DeadCodeProgrammaticOutput, DuplicationProgrammaticOutput, EngineHealthRunner,
118    HealthJsonReportInput, HealthProgrammaticOutput, ProgrammaticAnalysisContext,
119    ProgrammaticHealthNextStepFacts, ProgrammaticHealthRun, ProgrammaticHealthRunner,
120    compute_complexity_with_runner, compute_health, compute_health_with_runner,
121    derive_programmatic_health_execution_options, detect_boundary_violations,
122    detect_circular_dependencies, detect_dead_code, detect_duplication,
123    resolve_programmatic_analysis_context, run_boundary_violations, run_circular_dependencies,
124    run_complexity_with_runner, run_dead_code, run_duplication, run_health, run_health_with_runner,
125    serialize_health_report_json,
126};
127pub use sarif_output::{
128    annotate_sarif_results, build_duplication_sarif, build_grouped_duplication_sarif,
129    build_health_sarif,
130};
131pub use security_output::SecurityGateMode;
132
133pub const COMMON_ANALYSIS_OPTION_FLAGS: &[&str] = &[
134    "root",
135    "config",
136    "no-cache",
137    "threads",
138    "changed-since",
139    "diff-file",
140    "production",
141    "workspace",
142    "changed-workspaces",
143    "explain",
144    "legacy-envelope",
145];
146
147/// Structured error surface for the programmatic API.
148#[derive(Debug, Clone, Serialize)]
149pub struct ProgrammaticError {
150    pub message: String,
151    pub exit_code: u8,
152    pub code: Option<String>,
153    pub help: Option<String>,
154    pub context: Option<String>,
155}
156
157impl ProgrammaticError {
158    #[must_use]
159    pub fn new(message: impl Into<String>, exit_code: u8) -> Self {
160        Self {
161            message: message.into(),
162            exit_code,
163            code: None,
164            help: None,
165            context: None,
166        }
167    }
168
169    #[must_use]
170    pub fn with_help(mut self, help: impl Into<String>) -> Self {
171        self.help = Some(help.into());
172        self
173    }
174
175    #[must_use]
176    pub fn with_code(mut self, code: impl Into<String>) -> Self {
177        self.code = Some(code.into());
178        self
179    }
180
181    #[must_use]
182    pub fn with_context(mut self, context: impl Into<String>) -> Self {
183        self.context = Some(context.into());
184        self
185    }
186}
187
188impl std::fmt::Display for ProgrammaticError {
189    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190        write!(f, "{}", self.message)
191    }
192}
193
194impl std::error::Error for ProgrammaticError {}
195
196/// Shared options for all one-shot analyses.
197#[derive(Debug, Clone, Default)]
198pub struct AnalysisOptions {
199    pub root: Option<PathBuf>,
200    pub config_path: Option<PathBuf>,
201    pub no_cache: bool,
202    pub threads: Option<usize>,
203    pub diff_file: Option<PathBuf>,
204    /// Legacy convenience override. `true` forces production mode; `false`
205    /// defers to config unless `production_override` is set.
206    pub production: bool,
207    /// Explicit production override from an embedder option. `None` means
208    /// use the project config for the current analysis.
209    pub production_override: Option<bool>,
210    pub changed_since: Option<String>,
211    pub workspace: Option<Vec<String>>,
212    pub changed_workspaces: Option<String>,
213    pub explain: bool,
214    /// Return the one-cycle legacy root envelope without top-level `kind`.
215    pub legacy_envelope: bool,
216}
217
218/// Issue-type filters for the dead-code analysis.
219#[derive(Debug, Clone, Default)]
220pub struct DeadCodeFilters {
221    pub unused_files: bool,
222    pub unused_exports: bool,
223    pub unused_deps: bool,
224    pub unused_types: bool,
225    pub private_type_leaks: bool,
226    pub unused_enum_members: bool,
227    pub unused_class_members: bool,
228    pub unused_store_members: bool,
229    pub unprovided_injects: bool,
230    pub unrendered_components: bool,
231    pub unused_component_props: bool,
232    pub unused_component_emits: bool,
233    pub unused_component_inputs: bool,
234    pub unused_component_outputs: bool,
235    pub unused_svelte_events: bool,
236    pub unused_server_actions: bool,
237    pub unused_load_data_keys: bool,
238    pub unresolved_imports: bool,
239    pub unlisted_deps: bool,
240    pub duplicate_exports: bool,
241    pub circular_deps: bool,
242    pub re_export_cycles: bool,
243    pub boundary_violations: bool,
244    pub policy_violations: bool,
245    pub stale_suppressions: bool,
246    pub unused_catalog_entries: bool,
247    pub empty_catalog_groups: bool,
248    pub unresolved_catalog_references: bool,
249    pub unused_dependency_overrides: bool,
250    pub misconfigured_dependency_overrides: bool,
251}
252
253/// Options for dead-code-oriented analyses.
254#[derive(Debug, Clone, Default)]
255pub struct DeadCodeOptions {
256    pub analysis: AnalysisOptions,
257    pub filters: DeadCodeFilters,
258    pub files: Vec<PathBuf>,
259    pub include_entry_exports: bool,
260}
261
262/// Programmatic duplication mode selection.
263#[derive(Debug, Clone, Copy, Default)]
264pub enum DuplicationMode {
265    Strict,
266    #[default]
267    Mild,
268    Weak,
269    Semantic,
270}
271
272/// Options for duplication analysis.
273#[derive(Debug, Clone)]
274pub struct DuplicationOptions {
275    pub analysis: AnalysisOptions,
276    pub mode: DuplicationMode,
277    pub min_tokens: usize,
278    pub min_lines: usize,
279    /// Minimum number of occurrences before a clone group is reported.
280    /// Values below 2 are silently treated as 2 by the engine-facing adapter.
281    pub min_occurrences: usize,
282    pub threshold: f64,
283    pub skip_local: bool,
284    pub cross_language: bool,
285    /// Exclude module wiring from clone detection. `None` defers to the project
286    /// config.
287    pub ignore_imports: Option<bool>,
288    pub top: Option<usize>,
289}
290
291impl Default for DuplicationOptions {
292    fn default() -> Self {
293        Self {
294            analysis: AnalysisOptions::default(),
295            mode: DuplicationMode::Mild,
296            min_tokens: 50,
297            min_lines: 5,
298            min_occurrences: 2,
299            threshold: 0.0,
300            skip_local: false,
301            cross_language: false,
302            ignore_imports: None,
303            top: None,
304        }
305    }
306}
307
308/// Sort criteria for complexity findings.
309#[derive(Debug, Clone, Copy, Default)]
310pub enum ComplexitySort {
311    #[default]
312    Cyclomatic,
313    Cognitive,
314    Lines,
315    Severity,
316}
317
318/// Privacy mode for ownership-aware hotspot output.
319#[derive(Debug, Clone, Copy, Default)]
320pub enum OwnershipEmailMode {
321    Raw,
322    #[default]
323    Handle,
324    Anonymized,
325    /// Legacy spelling retained for embedders that already pass `hash`.
326    Hash,
327}
328
329/// Effort filter for refactoring targets.
330#[derive(Debug, Clone, Copy)]
331pub enum TargetEffort {
332    Low,
333    Medium,
334    High,
335}
336
337/// Options for complexity / health analysis.
338#[derive(Debug, Clone, Default)]
339pub struct ComplexityOptions {
340    pub analysis: AnalysisOptions,
341    pub max_cyclomatic: Option<u16>,
342    pub max_cognitive: Option<u16>,
343    pub max_crap: Option<f64>,
344    pub top: Option<usize>,
345    pub sort: ComplexitySort,
346    pub complexity: bool,
347    pub file_scores: bool,
348    pub coverage_gaps: bool,
349    pub hotspots: bool,
350    pub ownership: bool,
351    pub ownership_emails: Option<OwnershipEmailMode>,
352    pub targets: bool,
353    pub css: bool,
354    pub effort: Option<TargetEffort>,
355    pub score: bool,
356    pub since: Option<String>,
357    pub min_commits: Option<u32>,
358    pub coverage: Option<PathBuf>,
359    pub coverage_root: Option<PathBuf>,
360}
361
362pub use fallow_engine::{
363    ComplexityRunOptions, ComplexitySectionOptions, DerivedComplexityOptions,
364    DerivedHealthSections, HealthSectionOptions, derive_complexity_sections,
365    derive_health_sections,
366};
367
368/// Derive effective programmatic health / complexity section flags.
369#[must_use]
370pub fn derive_complexity_options(options: &ComplexityOptions) -> DerivedComplexityOptions {
371    derive_complexity_sections(&complexity_section_options(options))
372}
373
374/// Normalize public API complexity options into engine-owned run contracts.
375#[must_use]
376pub fn derive_complexity_run_options(options: &ComplexityOptions) -> ComplexityRunOptions<'_> {
377    ComplexityRunOptions {
378        thresholds: fallow_engine::HealthThresholdOverrides {
379            max_cyclomatic: options.max_cyclomatic,
380            max_cognitive: options.max_cognitive,
381            max_crap: options.max_crap,
382        },
383        top: options.top,
384        sort: complexity_sort_to_engine(options.sort),
385        sections: derive_complexity_options(options),
386        ownership_emails: options.ownership_emails.map(ownership_email_mode_to_config),
387        effort: options.effort.map(target_effort_to_output),
388        css: options.css,
389        since: options.since.as_deref(),
390        min_commits: options.min_commits,
391        coverage_inputs: fallow_engine::HealthCoverageInputs {
392            coverage: options.coverage.as_deref(),
393            coverage_root: options.coverage_root.as_deref(),
394        },
395    }
396}
397
398/// Validate programmatic complexity / health inputs before invoking a concrete
399/// runner.
400///
401/// These option contracts belong to the API boundary because NAPI and future
402/// Rust embedders construct the same [`ComplexityOptions`] type.
403///
404/// # Errors
405///
406/// Returns a structured programmatic error when a coverage path does not exist
407/// or when `coverage_root` is not an absolute prefix from the coverage data.
408pub fn validate_complexity_options(options: &ComplexityOptions) -> Result<(), ProgrammaticError> {
409    if let Some(path) = &options.coverage
410        && !path.exists()
411    {
412        return Err(ProgrammaticError::new(
413            format!("coverage path does not exist: {}", path.display()),
414            2,
415        )
416        .with_code("FALLOW_INVALID_COVERAGE_PATH")
417        .with_context("health.coverage"));
418    }
419    if let Err(message) =
420        fallow_engine::validate_coverage_root_absolute(options.coverage_root.as_deref())
421    {
422        return Err(ProgrammaticError::new(message, 2)
423            .with_code("FALLOW_INVALID_COVERAGE_ROOT")
424            .with_context("health.coverage_root"));
425    }
426
427    Ok(())
428}
429
430fn complexity_section_options(options: &ComplexityOptions) -> ComplexitySectionOptions {
431    let ownership = options.ownership || options.ownership_emails.is_some();
432    let requested_targets = options.targets || options.effort.is_some();
433    ComplexitySectionOptions {
434        complexity: options.complexity,
435        file_scores: options.file_scores,
436        coverage_gaps: options.coverage_gaps,
437        hotspots: options.hotspots,
438        ownership,
439        targets: requested_targets,
440        css: options.css,
441        score: options.score,
442    }
443}
444
445const fn complexity_sort_to_engine(sort: ComplexitySort) -> fallow_engine::HealthSort {
446    match sort {
447        ComplexitySort::Severity => fallow_engine::HealthSort::Severity,
448        ComplexitySort::Cyclomatic => fallow_engine::HealthSort::Cyclomatic,
449        ComplexitySort::Cognitive => fallow_engine::HealthSort::Cognitive,
450        ComplexitySort::Lines => fallow_engine::HealthSort::Lines,
451    }
452}
453
454const fn ownership_email_mode_to_config(mode: OwnershipEmailMode) -> EmailMode {
455    match mode {
456        OwnershipEmailMode::Raw => EmailMode::Raw,
457        OwnershipEmailMode::Handle => EmailMode::Handle,
458        OwnershipEmailMode::Anonymized => EmailMode::Anonymized,
459        OwnershipEmailMode::Hash => EmailMode::Hash,
460    }
461}
462
463const fn target_effort_to_output(effort: TargetEffort) -> EffortEstimate {
464    match effort {
465        TargetEffort::Low => EffortEstimate::Low,
466        TargetEffort::Medium => EffortEstimate::Medium,
467        TargetEffort::High => EffortEstimate::High,
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    #[test]
476    fn duplication_defaults_match_cli_contract() {
477        let options = DuplicationOptions::default();
478        assert!(matches!(options.mode, DuplicationMode::Mild));
479        assert_eq!(options.min_tokens, 50);
480        assert_eq!(options.min_lines, 5);
481        assert_eq!(options.min_occurrences, 2);
482    }
483
484    #[test]
485    fn programmatic_error_builder_keeps_optional_fields() {
486        let error = ProgrammaticError::new("boom", 2)
487            .with_code("FALLOW_TEST")
488            .with_help("Try again")
489            .with_context("analysis.root");
490
491        assert_eq!(error.message, "boom");
492        assert_eq!(error.exit_code, 2);
493        assert_eq!(error.code.as_deref(), Some("FALLOW_TEST"));
494        assert_eq!(error.help.as_deref(), Some("Try again"));
495        assert_eq!(error.context.as_deref(), Some("analysis.root"));
496    }
497
498    #[test]
499    fn default_complexity_options_match_programmatic_health_defaults() {
500        let derived = derive_complexity_options(&ComplexityOptions::default());
501
502        assert!(!derived.any_section);
503        assert!(derived.complexity);
504        assert!(derived.file_scores);
505        assert!(!derived.coverage_gaps);
506        assert!(derived.hotspots);
507        assert!(!derived.ownership);
508        assert!(derived.targets);
509        assert!(derived.force_full);
510        assert!(!derived.score_only_output);
511        assert!(derived.score);
512    }
513
514    #[test]
515    fn score_only_complexity_options_request_score_only_output() {
516        let derived = derive_complexity_options(&ComplexityOptions {
517            score: true,
518            ..ComplexityOptions::default()
519        });
520
521        assert!(derived.any_section);
522        assert!(!derived.complexity);
523        assert!(derived.file_scores);
524        assert!(!derived.hotspots);
525        assert!(!derived.targets);
526        assert!(derived.force_full);
527        assert!(derived.score_only_output);
528        assert!(derived.score);
529    }
530
531    #[test]
532    fn ownership_implies_hotspots_when_requested() {
533        let derived = derive_complexity_options(&ComplexityOptions {
534            ownership: true,
535            ..ComplexityOptions::default()
536        });
537
538        assert!(derived.any_section);
539        assert!(derived.hotspots);
540        assert!(derived.ownership);
541        assert!(!derived.targets);
542    }
543
544    #[test]
545    fn complexity_run_options_normalize_public_api_options() {
546        let options = ComplexityOptions {
547            max_cyclomatic: Some(42),
548            max_cognitive: Some(21),
549            max_crap: Some(18.5),
550            top: Some(7),
551            sort: ComplexitySort::Severity,
552            ownership_emails: Some(OwnershipEmailMode::Hash),
553            effort: Some(TargetEffort::High),
554            coverage: Some(PathBuf::from("coverage/coverage-final.json")),
555            coverage_root: Some(PathBuf::from("/ci/workspace")),
556            since: Some("30d".to_string()),
557            min_commits: Some(4),
558            ..ComplexityOptions::default()
559        };
560
561        let run = derive_complexity_run_options(&options);
562
563        assert_eq!(run.thresholds.max_cyclomatic, Some(42));
564        assert_eq!(run.thresholds.max_cognitive, Some(21));
565        assert_eq!(run.thresholds.max_crap, Some(18.5));
566        assert_eq!(run.top, Some(7));
567        assert!(matches!(run.sort, fallow_engine::HealthSort::Severity));
568        assert!(run.sections.hotspots);
569        assert!(run.sections.ownership);
570        assert!(run.sections.targets);
571        assert!(matches!(
572            run.ownership_emails,
573            Some(fallow_config::EmailMode::Hash)
574        ));
575        assert!(matches!(
576            run.effort,
577            Some(fallow_output::EffortEstimate::High)
578        ));
579        assert_eq!(run.since, Some("30d"));
580        assert_eq!(run.min_commits, Some(4));
581        assert_eq!(run.coverage_inputs.coverage, options.coverage.as_deref());
582        assert_eq!(
583            run.coverage_inputs.coverage_root,
584            options.coverage_root.as_deref()
585        );
586    }
587
588    #[test]
589    fn complexity_options_validation_accepts_existing_coverage_path_and_absolute_root() {
590        let dir = tempfile::tempdir().expect("tempdir");
591        let coverage = dir.path().join("coverage-final.json");
592        std::fs::write(&coverage, "{}").expect("coverage fixture");
593
594        let result = validate_complexity_options(&ComplexityOptions {
595            coverage: Some(coverage),
596            coverage_root: Some(PathBuf::from("/ci/workspace")),
597            ..ComplexityOptions::default()
598        });
599
600        assert!(result.is_ok());
601    }
602
603    #[test]
604    fn complexity_options_validation_keeps_missing_coverage_error_contract() {
605        let err = validate_complexity_options(&ComplexityOptions {
606            coverage: Some(PathBuf::from("/missing/coverage-final.json")),
607            ..ComplexityOptions::default()
608        })
609        .expect_err("missing coverage path should fail");
610
611        assert_eq!(err.exit_code, 2);
612        assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_PATH"));
613        assert_eq!(err.context.as_deref(), Some("health.coverage"));
614    }
615
616    #[test]
617    fn complexity_options_validation_keeps_relative_coverage_root_error_contract() {
618        let err = validate_complexity_options(&ComplexityOptions {
619            coverage_root: Some(PathBuf::from("coverage")),
620            ..ComplexityOptions::default()
621        })
622        .expect_err("relative coverage root should fail");
623
624        assert_eq!(err.exit_code, 2);
625        assert_eq!(err.code.as_deref(), Some("FALLOW_INVALID_COVERAGE_ROOT"));
626        assert_eq!(err.context.as_deref(), Some("health.coverage_root"));
627    }
628
629    #[test]
630    fn default_health_sections_match_full_health_output() {
631        let derived = derive_health_sections(&HealthSectionOptions {
632            output: fallow_config::OutputFormat::Human,
633            complexity: false,
634            file_scores: false,
635            coverage_gaps: false,
636            hotspots: false,
637            targets: false,
638            css: false,
639            score: false,
640            score_gate: false,
641            snapshot_requested: false,
642            trend: false,
643        });
644
645        assert!(!derived.any_section);
646        assert!(derived.complexity);
647        assert!(derived.file_scores);
648        assert!(!derived.coverage_gaps);
649        assert!(derived.hotspots);
650        assert!(derived.targets);
651        assert!(derived.score);
652        assert!(derived.force_full);
653        assert!(!derived.score_only_output);
654    }
655
656    #[test]
657    fn health_score_gate_requests_score_only_output() {
658        let derived = derive_health_sections(&HealthSectionOptions {
659            output: fallow_config::OutputFormat::Human,
660            complexity: false,
661            file_scores: false,
662            coverage_gaps: false,
663            hotspots: false,
664            targets: false,
665            css: false,
666            score: false,
667            score_gate: true,
668            snapshot_requested: false,
669            trend: false,
670        });
671
672        assert!(derived.any_section);
673        assert!(!derived.complexity);
674        assert!(derived.file_scores);
675        assert!(!derived.hotspots);
676        assert!(!derived.targets);
677        assert!(derived.score);
678        assert!(derived.force_full);
679        assert!(derived.score_only_output);
680    }
681
682    #[test]
683    fn health_snapshot_keeps_full_hidden_inputs_without_section_request() {
684        let derived = derive_health_sections(&HealthSectionOptions {
685            output: fallow_config::OutputFormat::Human,
686            complexity: false,
687            file_scores: false,
688            coverage_gaps: false,
689            hotspots: false,
690            targets: false,
691            css: true,
692            score: false,
693            score_gate: false,
694            snapshot_requested: true,
695            trend: false,
696        });
697
698        assert!(!derived.any_section);
699        assert!(derived.css);
700        assert!(derived.file_scores);
701        assert!(derived.hotspots);
702        assert!(derived.score);
703        assert!(derived.force_full);
704    }
705}