openapi_snapshot/
output.rs

1use 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}