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 target-level git churn. Omitted unless historical evidence was
106    /// explicitly requested by the caller.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub churn: Option<InspectEvidenceSection>,
109    /// OPT-IN symbol-level call chain. Present only when `--symbol-chain` was
110    /// requested AND the target is a SYMBOL (best-effort, syntactic, OFF the
111    /// ranked path). `None` (omitted) by default: symbol-level chains are
112    /// best-effort and not part of the trusted ranked evidence.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub symbol_chain: Option<InspectEvidenceSection>,
115}
116
117#[derive(Debug, Clone, Serialize)]
118#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
119pub struct InspectEvidenceSection {
120    pub status: InspectSectionStatus,
121    pub scope: InspectEvidenceScope,
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub message: Option<String>,
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub data: Option<serde_json::Value>,
126}
127
128impl InspectEvidenceSection {
129    #[must_use]
130    pub fn ok(scope: InspectEvidenceScope, data: serde_json::Value) -> Self {
131        Self {
132            status: InspectSectionStatus::Ok,
133            scope,
134            message: None,
135            data: Some(data),
136        }
137    }
138
139    #[must_use]
140    pub fn error(scope: InspectEvidenceScope, message: String) -> Self {
141        Self {
142            status: InspectSectionStatus::Error,
143            scope,
144            message: Some(message),
145            data: None,
146        }
147    }
148
149    #[must_use]
150    pub fn unavailable(scope: InspectEvidenceScope, message: String) -> Self {
151        Self {
152            status: InspectSectionStatus::Unavailable,
153            scope,
154            message: Some(message),
155            data: None,
156        }
157    }
158}
159
160/// Serialize the `fallow inspect --format json` envelope.
161///
162/// # Errors
163///
164/// Returns a serde error when the inspect output cannot be converted to JSON.
165pub fn serialize_inspect_json_output(
166    output: InspectOutput,
167    mode: RootEnvelopeMode,
168    analysis_run_id: Option<&str>,
169) -> Result<serde_json::Value, serde_json::Error> {
170    let mut value = serialize_named_json_output(output, "inspect_target", mode)?;
171    attach_telemetry_meta(&mut value, analysis_run_id);
172    Ok(value)
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
176#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
177#[serde(rename_all = "snake_case")]
178pub enum InspectSectionStatus {
179    Ok,
180    Unavailable,
181    Error,
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
185#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
186#[serde(rename_all = "snake_case")]
187pub enum InspectEvidenceScope {
188    Symbol,
189    File,
190    ProjectFilteredToFile,
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn explain_json_output_uses_output_owned_root_contract() {
199        let output = ExplainOutput {
200            id: "unused-export".to_string(),
201            name: "Unused export".to_string(),
202            summary: "summary".to_string(),
203            rationale: "rationale".to_string(),
204            example: "example".to_string(),
205            how_to_fix: "fix".to_string(),
206            docs: "https://example.test".to_string(),
207        };
208
209        let value =
210            serialize_explain_json_output(output, RootEnvelopeMode::Tagged, Some("run-explain"))
211                .expect("explain output should serialize");
212
213        assert_eq!(value["kind"], "explain");
214        assert_eq!(
215            value["_meta"]["telemetry"]["analysis_run_id"],
216            "run-explain"
217        );
218    }
219
220    #[test]
221    fn inspect_json_output_uses_output_owned_root_contract() {
222        let output = InspectOutput {
223            target: InspectTargetDescriptor::File {
224                file: "src/app.ts".to_string(),
225            },
226            identity: InspectIdentity::File(InspectFileIdentity {
227                file: "src/app.ts".to_string(),
228                is_reachable: None,
229                is_entry_point: None,
230                export_count: Some(0),
231                import_count: Some(0),
232                imported_by_count: Some(0),
233            }),
234            evidence: InspectEvidence {
235                trace_file: InspectEvidenceSection::ok(
236                    InspectEvidenceScope::File,
237                    serde_json::json!({}),
238                ),
239                trace_export: None,
240                dead_code: InspectEvidenceSection::error(
241                    InspectEvidenceScope::File,
242                    "not run".to_string(),
243                ),
244                duplication: InspectEvidenceSection::error(
245                    InspectEvidenceScope::ProjectFilteredToFile,
246                    "not run".to_string(),
247                ),
248                complexity: InspectEvidenceSection::error(
249                    InspectEvidenceScope::ProjectFilteredToFile,
250                    "not run".to_string(),
251                ),
252                security: InspectEvidenceSection::error(
253                    InspectEvidenceScope::File,
254                    "not run".to_string(),
255                ),
256                impact_closure: InspectEvidenceSection::error(
257                    InspectEvidenceScope::ProjectFilteredToFile,
258                    "not run".to_string(),
259                ),
260                churn: None,
261                symbol_chain: None,
262            },
263            warnings: Vec::new(),
264        };
265
266        let value =
267            serialize_inspect_json_output(output, RootEnvelopeMode::Tagged, Some("run-inspect"))
268                .expect("inspect output should serialize");
269
270        assert_eq!(value["kind"], "inspect_target");
271        assert_eq!(
272            value["_meta"]["telemetry"]["analysis_run_id"],
273            "run-inspect"
274        );
275        assert!(value["evidence"].get("churn").is_none());
276    }
277
278    #[test]
279    fn inspect_churn_section_serializes_success_unavailable_and_error_states() {
280        let ok = InspectEvidenceSection::ok(
281            InspectEvidenceScope::ProjectFilteredToFile,
282            serde_json::json!({"file": "src/app.ts", "commits": 4}),
283        );
284        let unavailable = InspectEvidenceSection::unavailable(
285            InspectEvidenceScope::ProjectFilteredToFile,
286            "git repository unavailable".to_string(),
287        );
288        let error = InspectEvidenceSection::error(
289            InspectEvidenceScope::ProjectFilteredToFile,
290            "git log failed".to_string(),
291        );
292
293        assert_eq!(
294            serde_json::to_value(ok).expect("ok section should serialize")["status"],
295            "ok"
296        );
297        assert_eq!(
298            serde_json::to_value(unavailable).expect("unavailable section should serialize")["status"],
299            "unavailable"
300        );
301        assert_eq!(
302            serde_json::to_value(error).expect("error section should serialize")["status"],
303            "error"
304        );
305    }
306}