Skip to main content

fallow_output/
inspect_envelopes.rs

1//! Explain and inspect output envelopes.
2
3use crate::root_envelopes::{RootEnvelopeMode, attach_telemetry_meta, serialize_named_json_output};
4use serde::Serialize;
5
6/// Envelope emitted by `fallow explain <issue-type> --format json`.
7///
8/// Standalone rule explanation. This command does not run project analysis
9/// and intentionally returns a compact object without `schema_version` /
10/// `version` metadata; consumers that need those should call any other
11/// fallow JSON-producing command.
12#[derive(Debug, Clone, Serialize)]
13#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
14#[cfg_attr(
15    feature = "schema",
16    schemars(title = "fallow explain <issue-type> --format json")
17)]
18pub struct ExplainOutput {
19    pub id: String,
20    pub name: String,
21    pub summary: String,
22    pub rationale: String,
23    pub example: String,
24    pub how_to_fix: String,
25    pub docs: String,
26}
27
28/// Serialize the `fallow explain --format json` envelope.
29///
30/// # Errors
31///
32/// Returns a serde error when the explain output cannot be converted to JSON.
33pub fn serialize_explain_json_output(
34    output: ExplainOutput,
35    mode: RootEnvelopeMode,
36    analysis_run_id: Option<&str>,
37) -> Result<serde_json::Value, serde_json::Error> {
38    let mut value = serialize_named_json_output(output, "explain", mode)?;
39    attach_telemetry_meta(&mut value, analysis_run_id);
40    Ok(value)
41}
42
43/// Envelope emitted by `fallow inspect --format json`.
44#[derive(Debug, Clone, Serialize)]
45#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
46#[cfg_attr(feature = "schema", schemars(title = "fallow inspect --format json"))]
47pub struct InspectOutput {
48    pub target: InspectTargetDescriptor,
49    pub identity: InspectIdentity,
50    pub evidence: InspectEvidence,
51    pub warnings: Vec<String>,
52}
53
54#[derive(Debug, Clone, Serialize)]
55#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
56#[serde(tag = "type", rename_all = "snake_case")]
57pub enum InspectTargetDescriptor {
58    File { file: String },
59    Symbol { file: String, export_name: String },
60}
61
62#[derive(Debug, Clone, Serialize)]
63#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
64#[serde(untagged)]
65pub enum InspectIdentity {
66    File(InspectFileIdentity),
67    Symbol(InspectSymbolIdentity),
68}
69
70#[derive(Debug, Clone, Serialize)]
71#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
72pub struct InspectFileIdentity {
73    pub file: String,
74    pub is_reachable: Option<serde_json::Value>,
75    pub is_entry_point: Option<serde_json::Value>,
76    pub export_count: Option<usize>,
77    pub import_count: Option<usize>,
78    pub imported_by_count: Option<usize>,
79}
80
81#[derive(Debug, Clone, Serialize)]
82#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
83pub struct InspectSymbolIdentity {
84    pub file: String,
85    pub export_name: String,
86    pub file_reachable: Option<serde_json::Value>,
87    pub is_entry_point: Option<serde_json::Value>,
88    pub is_used: Option<serde_json::Value>,
89    pub reason: Option<serde_json::Value>,
90}
91
92#[derive(Debug, Clone, Serialize)]
93#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
94pub struct InspectEvidence {
95    pub trace_file: InspectEvidenceSection,
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub trace_export: Option<InspectEvidenceSection>,
98    pub dead_code: InspectEvidenceSection,
99    pub duplication: InspectEvidenceSection,
100    pub complexity: InspectEvidenceSection,
101    pub security: InspectEvidenceSection,
102    /// Impact closure scoped to the inspected file as the seed: the transitive
103    /// affected-but-not-in-diff set + coordination gap.
104    pub impact_closure: InspectEvidenceSection,
105    /// OPT-IN symbol-level call chain. Present only when `--symbol-chain` was
106    /// requested AND the target is a SYMBOL (best-effort, syntactic, OFF the
107    /// ranked path). `None` (omitted) by default: symbol-level chains are
108    /// best-effort and not part of the trusted ranked evidence.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub symbol_chain: Option<InspectEvidenceSection>,
111}
112
113#[derive(Debug, Clone, Serialize)]
114#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
115pub struct InspectEvidenceSection {
116    pub status: InspectSectionStatus,
117    pub scope: InspectEvidenceScope,
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub message: Option<String>,
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub data: Option<serde_json::Value>,
122}
123
124impl InspectEvidenceSection {
125    #[must_use]
126    pub fn ok(scope: InspectEvidenceScope, data: serde_json::Value) -> Self {
127        Self {
128            status: InspectSectionStatus::Ok,
129            scope,
130            message: None,
131            data: Some(data),
132        }
133    }
134
135    #[must_use]
136    pub fn error(scope: InspectEvidenceScope, message: String) -> Self {
137        Self {
138            status: InspectSectionStatus::Error,
139            scope,
140            message: Some(message),
141            data: None,
142        }
143    }
144}
145
146/// Serialize the `fallow inspect --format json` envelope.
147///
148/// # Errors
149///
150/// Returns a serde error when the inspect output cannot be converted to JSON.
151pub fn serialize_inspect_json_output(
152    output: InspectOutput,
153    mode: RootEnvelopeMode,
154    analysis_run_id: Option<&str>,
155) -> Result<serde_json::Value, serde_json::Error> {
156    let mut value = serialize_named_json_output(output, "inspect_target", mode)?;
157    attach_telemetry_meta(&mut value, analysis_run_id);
158    Ok(value)
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
162#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
163#[serde(rename_all = "snake_case")]
164pub enum InspectSectionStatus {
165    Ok,
166    Error,
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
170#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
171#[serde(rename_all = "snake_case")]
172pub enum InspectEvidenceScope {
173    Symbol,
174    File,
175    ProjectFilteredToFile,
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn explain_json_output_uses_output_owned_root_contract() {
184        let output = ExplainOutput {
185            id: "unused-export".to_string(),
186            name: "Unused export".to_string(),
187            summary: "summary".to_string(),
188            rationale: "rationale".to_string(),
189            example: "example".to_string(),
190            how_to_fix: "fix".to_string(),
191            docs: "https://example.test".to_string(),
192        };
193
194        let value =
195            serialize_explain_json_output(output, RootEnvelopeMode::Tagged, Some("run-explain"))
196                .expect("explain output should serialize");
197
198        assert_eq!(value["kind"], "explain");
199        assert_eq!(
200            value["_meta"]["telemetry"]["analysis_run_id"],
201            "run-explain"
202        );
203    }
204
205    #[test]
206    fn inspect_json_output_uses_output_owned_root_contract() {
207        let output = InspectOutput {
208            target: InspectTargetDescriptor::File {
209                file: "src/app.ts".to_string(),
210            },
211            identity: InspectIdentity::File(InspectFileIdentity {
212                file: "src/app.ts".to_string(),
213                is_reachable: None,
214                is_entry_point: None,
215                export_count: Some(0),
216                import_count: Some(0),
217                imported_by_count: Some(0),
218            }),
219            evidence: InspectEvidence {
220                trace_file: InspectEvidenceSection::ok(
221                    InspectEvidenceScope::File,
222                    serde_json::json!({}),
223                ),
224                trace_export: None,
225                dead_code: InspectEvidenceSection::error(
226                    InspectEvidenceScope::File,
227                    "not run".to_string(),
228                ),
229                duplication: InspectEvidenceSection::error(
230                    InspectEvidenceScope::ProjectFilteredToFile,
231                    "not run".to_string(),
232                ),
233                complexity: InspectEvidenceSection::error(
234                    InspectEvidenceScope::ProjectFilteredToFile,
235                    "not run".to_string(),
236                ),
237                security: InspectEvidenceSection::error(
238                    InspectEvidenceScope::File,
239                    "not run".to_string(),
240                ),
241                impact_closure: InspectEvidenceSection::error(
242                    InspectEvidenceScope::ProjectFilteredToFile,
243                    "not run".to_string(),
244                ),
245                symbol_chain: None,
246            },
247            warnings: Vec::new(),
248        };
249
250        let value =
251            serialize_inspect_json_output(output, RootEnvelopeMode::Tagged, Some("run-inspect"))
252                .expect("inspect output should serialize");
253
254        assert_eq!(value["kind"], "inspect_target");
255        assert_eq!(
256            value["_meta"]["telemetry"]["analysis_run_id"],
257            "run-inspect"
258        );
259    }
260}