openapi_snapshot/
output.rs1use std::fs::{self, OpenOptions};
2use std::io::Write;
3use std::path::Path;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use serde_json::Value;
7
8use crate::cli::OutputProfile;
9use crate::config::{Config, ReduceKey};
10use crate::errors::AppError;
11use crate::fetch::{fetch_openapi, parse_json};
12use crate::outline::outline_openapi;
13
14#[derive(Debug)]
15pub struct OutputPayloads {
16 pub primary: String,
17 pub outline: Option<String>,
18}
19
20pub fn build_output(config: &Config) -> Result<String, AppError> {
21 Ok(build_outputs(config)?.primary)
22}
23
24pub fn build_outputs(config: &Config) -> Result<OutputPayloads, AppError> {
25 let body = fetch_openapi(config)?;
26 let json = parse_json(&body)?;
27 match config.profile {
28 OutputProfile::Full => {
29 let mut full_value = json.clone();
30 if !config.reduce.is_empty() {
31 full_value = reduce_openapi(full_value, &config.reduce)?;
32 }
33 let primary = serialize_json(&full_value, config.minify)?;
34 let outline = if config.outline_out.is_some() {
35 let outline_value = outline_openapi(&json)?;
36 Some(serialize_json(&outline_value, config.minify)?)
37 } else {
38 None
39 };
40 Ok(OutputPayloads { primary, outline })
41 }
42 OutputProfile::Outline => {
43 let outline_value = outline_openapi(&json)?;
44 let primary = serialize_json(&outline_value, config.minify)?;
45 Ok(OutputPayloads {
46 primary,
47 outline: None,
48 })
49 }
50 }
51}
52
53pub fn write_output(config: &Config, payload: &str) -> Result<(), AppError> {
54 if config.stdout {
55 println!("{payload}");
56 return Ok(());
57 }
58
59 let out_path = config
60 .out
61 .as_ref()
62 .ok_or_else(|| AppError::Usage("--out is required unless --stdout is set.".to_string()))?;
63 write_atomic(out_path, payload)
64}
65
66pub fn write_outputs(config: &Config, outputs: &OutputPayloads) -> Result<(), AppError> {
67 if config.stdout {
68 println!("{}", outputs.primary);
69 return Ok(());
70 }
71
72 let out_path = config
73 .out
74 .as_ref()
75 .ok_or_else(|| AppError::Usage("--out is required unless --stdout is set.".to_string()))?;
76 write_atomic(out_path, &outputs.primary)?;
77
78 if let (Some(outline_payload), Some(outline_path)) =
79 (outputs.outline.as_ref(), config.outline_out.as_ref())
80 {
81 write_atomic(outline_path, outline_payload)?;
82 }
83
84 Ok(())
85}
86
87fn reduce_openapi(value: Value, keys: &[ReduceKey]) -> Result<Value, AppError> {
88 let object = value
89 .as_object()
90 .ok_or_else(|| AppError::Reduce("OpenAPI document must be a JSON object".to_string()))?;
91 let mut reduced = serde_json::Map::new();
92 for key in keys {
93 let name = key.as_str();
94 let entry = object
95 .get(name)
96 .ok_or_else(|| AppError::Reduce(format!("missing top-level key: {name}")))?;
97 reduced.insert(name.to_string(), entry.clone());
98 }
99 Ok(Value::Object(reduced))
100}
101
102fn serialize_json(value: &Value, minify: bool) -> Result<String, AppError> {
103 if minify {
104 serde_json::to_string(value).map_err(|err| AppError::Json(format!("json error: {err}")))
105 } else {
106 serde_json::to_string_pretty(value)
107 .map_err(|err| AppError::Json(format!("json error: {err}")))
108 }
109}
110
111fn write_atomic(path: &Path, contents: &str) -> Result<(), AppError> {
112 let parent = path
113 .parent()
114 .ok_or_else(|| AppError::Io("output path has no parent directory".to_string()))?;
115 if let Err(err) = fs::create_dir_all(parent) {
116 return Err(AppError::Io(format!(
117 "failed to create output directory: {err}"
118 )));
119 }
120
121 let timestamp = SystemTime::now()
122 .duration_since(UNIX_EPOCH)
123 .unwrap_or_default()
124 .as_millis();
125 let temp_name = format!(
126 ".{}.{}.tmp",
127 path.file_name()
128 .and_then(|name| name.to_str())
129 .unwrap_or("openapi_snapshot"),
130 timestamp
131 );
132 let temp_path = parent.join(temp_name);
133
134 let mut file = OpenOptions::new()
135 .create_new(true)
136 .write(true)
137 .open(&temp_path)
138 .map_err(|err| AppError::Io(format!("failed to create temp file: {err}")))?;
139
140 if let Err(err) = file.write_all(contents.as_bytes()) {
141 let _ = fs::remove_file(&temp_path);
142 return Err(AppError::Io(format!("failed to write temp file: {err}")));
143 }
144
145 if let Err(err) = file.sync_all() {
146 let _ = fs::remove_file(&temp_path);
147 return Err(AppError::Io(format!("failed to flush temp file: {err}")));
148 }
149
150 if let Err(err) = fs::rename(&temp_path, path) {
151 let _ = fs::remove_file(&temp_path);
152 return Err(AppError::Io(format!("failed to move temp file: {err}")));
153 }
154
155 Ok(())
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161 use serde_json::json;
162
163 #[test]
164 fn reduce_openapi_keeps_only_requested_keys() {
165 let input = json!({
166 "paths": {"x": 1},
167 "components": {"y": 2},
168 "extra": {"z": 3}
169 });
170 let output = reduce_openapi(input, &[ReduceKey::Components]).unwrap();
171 assert!(output.get("paths").is_none());
172 assert!(output.get("components").is_some());
173 assert!(output.get("extra").is_none());
174 }
175
176 #[test]
177 fn reduce_openapi_missing_key_is_error() {
178 let input = json!({"paths": {"x": 1}});
179 let err = reduce_openapi(input, &[ReduceKey::Components]).unwrap_err();
180 assert!(matches!(err, AppError::Reduce(_)));
181 }
182
183 #[test]
184 fn reduce_openapi_requires_object() {
185 let input = json!(["not an object"]);
186 let err = reduce_openapi(input, &[ReduceKey::Components]).unwrap_err();
187 assert!(matches!(err, AppError::Reduce(_)));
188 }
189}