Skip to main content

harness_webfetch/
format.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use url::Url;
4
5use crate::types::FetchMetadata;
6
7pub fn render_request_block(meta: &FetchMetadata) -> String {
8    let chain = meta.redirect_chain.join(" -> ");
9    format!(
10        "<request>\n  <url>{}</url>\n  <final_url>{}</final_url>\n  <method>{}</method>\n  <status>{}</status>\n  <content_type>{}</content_type>\n  <redirect_chain>{}</redirect_chain>\n</request>",
11        meta.url,
12        meta.final_url,
13        meta.method.as_str(),
14        meta.status,
15        meta.content_type,
16        chain,
17    )
18}
19
20pub struct FormatOkArgs<'a> {
21    pub meta: &'a FetchMetadata,
22    pub extract_hint: &'a str,
23    pub markdown: Option<&'a str>,
24    pub raw: Option<&'a str>,
25    pub log_path: Option<&'a str>,
26    pub byte_cap: bool,
27    pub body_clipped: bool,
28    pub total_bytes: usize,
29}
30
31pub fn format_ok_text(args: FormatOkArgs<'_>) -> String {
32    let header = render_request_block(args.meta);
33    let body_inner = match args.extract_hint {
34        "markdown" => args.markdown.unwrap_or("").to_string(),
35        "raw" => args.raw.unwrap_or("").to_string(),
36        "both" => format!(
37            "<markdown>\n{}\n</markdown>\n<raw_body>\n{}\n</raw_body>",
38            args.markdown.unwrap_or(""),
39            args.raw.unwrap_or(""),
40        ),
41        _ => String::new(),
42    };
43    let body_block = format!(
44        "<body extract=\"{}\">\n{}\n</body>",
45        args.extract_hint, body_inner
46    );
47
48    let hint = if args.byte_cap && args.log_path.is_some() {
49        let shown = if args.body_clipped {
50            format!(
51                "showing 64 KB head+tail preview of {} bytes",
52                args.total_bytes
53            )
54        } else {
55            format!(
56                "showing extracted content inline from {} raw bytes",
57                args.total_bytes
58            )
59        };
60        format!(
61            "(Response exceeded inline cap; {}. Full response at {} — Read with offset/limit to paginate.)",
62            shown,
63            args.log_path.unwrap(),
64        )
65    } else {
66        let original_host = host_of(&args.meta.url);
67        let final_host = host_of(&args.meta.final_url);
68        let warn = if args.meta.url != args.meta.final_url && original_host != final_host {
69            format!(
70                " (Final URL host differs from original: {} -> {}. Verify this is expected.)",
71                original_host, final_host
72            )
73        } else {
74            String::new()
75        };
76        let cache_tag = if args.meta.from_cache {
77            let age = args.meta.cache_age_sec.unwrap_or(0);
78            format!(" (Served from session cache; age {}s.)", age)
79        } else {
80            String::new()
81        };
82        let ct = if args.meta.content_type.is_empty() {
83            "unknown".to_string()
84        } else {
85            args.meta.content_type.clone()
86        };
87        format!(
88            "(Response complete. {} bytes total. Content-type: {}. Fetched in {}ms.{}{})",
89            args.total_bytes, ct, args.meta.fetched_ms, warn, cache_tag
90        )
91    };
92
93    format!("{}\n{}\n{}", header, body_block, hint)
94}
95
96pub struct FormatRedirectLoopArgs<'a> {
97    pub meta: &'a FetchMetadata,
98    pub max_redirects: u32,
99}
100
101pub fn format_redirect_loop_text(args: FormatRedirectLoopArgs<'_>) -> String {
102    let header = render_request_block(args.meta);
103    let chain = args.meta.redirect_chain.join(" -> ");
104    let hint = format!(
105        "(Redirect limit ({}) exceeded. Chain: {}. Set max_redirects higher OR pass the final URL directly.)",
106        args.max_redirects, chain
107    );
108    format!("{}\n{}", header, hint)
109}
110
111pub struct FormatHttpErrorArgs<'a> {
112    pub meta: &'a FetchMetadata,
113    pub body: &'a str,
114    pub log_path: Option<&'a str>,
115    pub byte_cap: bool,
116    pub total_bytes: usize,
117}
118
119pub fn format_http_error_text(args: FormatHttpErrorArgs<'_>) -> String {
120    let header = render_request_block(args.meta);
121    let body_block = format!("<body>\n{}\n</body>", args.body);
122    let spill_hint = if args.byte_cap && args.log_path.is_some() {
123        format!(
124            " Body exceeded inline cap; showing 64 KB head+tail preview of {} bytes. Full response at {} — Read with offset/limit to paginate.",
125            args.total_bytes,
126            args.log_path.unwrap(),
127        )
128    } else {
129        String::new()
130    };
131    let hint = format!(
132        "(HTTP {}. {}.{} Retry or adjust the request per the body.)",
133        args.meta.status,
134        short_reason(args.meta.status),
135        spill_hint,
136    );
137    format!("{}\n{}\n{}", header, body_block, hint)
138}
139
140fn short_reason(status: u16) -> &'static str {
141    match status {
142        400 => "Bad Request",
143        401 => "Unauthorized — check auth headers",
144        403 => "Forbidden — check permissions or auth",
145        404 => "Not Found",
146        408 => "Request Timeout",
147        410 => "Gone",
148        418 => "I'm a teapot",
149        429 => "Too Many Requests — back off",
150        500 => "Internal Server Error",
151        502 => "Bad Gateway",
152        503 => "Service Unavailable",
153        504 => "Gateway Timeout",
154        s if (400..500).contains(&s) => "Client error",
155        s if s >= 500 => "Server error",
156        _ => "Non-success status",
157    }
158}
159
160pub fn host_of(url: &str) -> String {
161    Url::parse(url)
162        .ok()
163        .and_then(|u| u.host_str().map(|s| s.to_string()))
164        .unwrap_or_default()
165}
166
167// ---- spill-to-file ----
168
169pub struct SpillArgs<'a> {
170    pub bytes: &'a [u8],
171    pub dir: &'a Path,
172    pub session_id: &'a str,
173    pub content_type: &'a str,
174}
175
176pub fn spill_to_file(args: SpillArgs<'_>) -> std::io::Result<PathBuf> {
177    let dir = args.dir.join(args.session_id);
178    fs::create_dir_all(&dir)?;
179    let ext = extension_for(args.content_type);
180    let filename = format!("{}.{}", uuid::Uuid::new_v4(), ext);
181    let path = dir.join(filename);
182    fs::write(&path, args.bytes)?;
183    Ok(path)
184}
185
186fn extension_for(content_type: &str) -> &'static str {
187    let lower = content_type.to_ascii_lowercase();
188    if lower.contains("text/html") || lower.contains("xhtml") {
189        "html"
190    } else if lower.contains("json") {
191        "json"
192    } else if lower.contains("xml") {
193        "xml"
194    } else if lower.contains("csv") {
195        "csv"
196    } else if lower.contains("markdown") {
197        "md"
198    } else if lower.contains("text/") {
199        "txt"
200    } else {
201        "bin"
202    }
203}
204
205/// Return head (first N bytes) + tail (last N bytes) concatenated with
206/// an elision marker. Mirrors the bash head+tail spill pattern.
207pub fn head_and_tail(bytes: &[u8], head_bytes: usize, tail_bytes: usize, log_path: &str) -> String {
208    if bytes.len() <= head_bytes + tail_bytes {
209        return String::from_utf8_lossy(bytes).into_owned();
210    }
211    let head = String::from_utf8_lossy(&bytes[..head_bytes]).into_owned();
212    let tail = String::from_utf8_lossy(&bytes[bytes.len() - tail_bytes..]).into_owned();
213    let elided = bytes.len() - head_bytes - tail_bytes;
214    format!(
215        "{}\n\n... ({} bytes elided; full response at {}) ...\n\n{}",
216        head, elided, log_path, tail
217    )
218}