Skip to content

Commit f673a12

Browse files
authored
Merge pull request #253 from ryanfowler/output-non-term
Write output info to stderr when not a tty
2 parents 416aad8 + b97a22b commit f673a12

3 files changed

Lines changed: 177 additions & 114 deletions

File tree

‎internal/fetch/fetch.go‎

Lines changed: 6 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111
"net/url"
1212
"os"
1313
"os/exec"
14-
"path/filepath"
1514
"slices"
1615
"strings"
1716
"time"
@@ -180,7 +179,7 @@ func makeRequest(ctx context.Context, r *Request, c *client.Client, req *http.Re
180179
p.Flush()
181180
}
182181

183-
body, err := formatResponse(ctx, r, resp, r.PrinterHandle.Stdout())
182+
body, err := formatResponse(ctx, r, resp)
184183
if err != nil {
185184
return 0, err
186185
}
@@ -196,51 +195,23 @@ func makeRequest(ctx context.Context, r *Request, c *client.Client, req *http.Re
196195
return exitCode, nil
197196
}
198197

199-
func formatResponse(ctx context.Context, r *Request, resp *http.Response, p *core.Printer) (io.Reader, error) {
198+
func formatResponse(ctx context.Context, r *Request, resp *http.Response) (io.Reader, error) {
200199
output, err := getOutputValue(r, resp.Header)
201200
if err != nil {
202201
return nil, err
203202
}
204203

205204
if output != "" && r.Output != "-" {
206-
f, err := os.Create(output)
207-
if err != nil {
208-
return nil, err
209-
}
210-
defer f.Close()
211-
name, err := filepath.Abs(f.Name())
212-
if err != nil {
213-
return nil, err
214-
}
215-
216-
// Optionally show a progress bar/spinner on stderr.
217-
var body io.Reader = resp.Body
218-
if r.Verbosity > core.VSilent && core.IsStderrTerm {
219-
p := r.PrinterHandle.Stderr()
220-
contentLength := resp.ContentLength
221-
if contentLength > 0 {
222-
pb := newProgressBar(resp.Body, p, contentLength)
223-
defer func() { pb.Close(name, err) }()
224-
body = pb
225-
} else {
226-
ps := newProgressSpinner(resp.Body, p)
227-
defer func() { ps.Close(name, err) }()
228-
body = ps
229-
}
230-
}
231-
232-
if _, err = io.Copy(f, body); err != nil {
233-
return nil, err
234-
}
235-
236-
err = f.Sync()
237-
return nil, err
205+
size := resp.ContentLength
206+
p := r.PrinterHandle.Stderr()
207+
return nil, writeOutputToFile(output, resp.Body, size, p, r.Verbosity)
238208
}
239209

240210
if r.Format == core.FormatOff || (!core.IsStdoutTerm && r.Format != core.FormatOn) {
241211
return resp.Body, nil
242212
}
243213

214+
p := r.PrinterHandle.Stdout()
244215
contentType := getContentType(resp.Header)
245216
switch contentType {
246217
case TypeUnknown:
@@ -387,67 +358,6 @@ func addHeader(headers []core.KeyVal, h core.KeyVal) []core.KeyVal {
387358
return slices.Insert(headers, i, h)
388359
}
389360

390-
func getOutputValue(r *Request, hdrs http.Header) (string, error) {
391-
if r.Output != "" {
392-
// Output was provided directly.
393-
return r.Output, nil
394-
}
395-
if !r.OutputDir {
396-
// Remote output option wasn't provided, return an empty string.
397-
return "", nil
398-
}
399-
400-
// Attempt to get filename from the Content-Disposition header first.
401-
cdName := getContentDispositionFilename(hdrs)
402-
if cdName != "" {
403-
return cdName, nil
404-
}
405-
406-
// Get the final path component as the file name.
407-
path := r.URL.Path
408-
if !strings.HasPrefix(path, "/") {
409-
path = "/" + path
410-
}
411-
for path != "" {
412-
var after string
413-
path, after, _ = cutLast(path, "/")
414-
if after != "" {
415-
return after, nil
416-
}
417-
418-
}
419-
420-
// Fallback to the hostname as the file path and emit a warning.
421-
host := r.URL.Hostname()
422-
if host != "" {
423-
return host, nil
424-
}
425-
426-
return "", errNoInferFilePath{}
427-
}
428-
429-
func getContentDispositionFilename(hdrs http.Header) string {
430-
cd := hdrs.Get("Content-Disposition")
431-
if cd == "" {
432-
return ""
433-
}
434-
435-
_, params, err := mime.ParseMediaType(cd)
436-
if err != nil {
437-
return ""
438-
}
439-
440-
return params["filename"]
441-
}
442-
443-
func cutLast(s, sep string) (string, string, bool) {
444-
idx := strings.LastIndex(s, sep)
445-
if idx < 0 {
446-
return s, "", false
447-
}
448-
return s[:idx], s[idx+1:], true
449-
}
450-
451361
// isCertificateErr returns true if the error has to do with TLS cert validation.
452362
func isCertificateErr(err error) bool {
453363
var urlErr *url.Error
@@ -477,20 +387,3 @@ func printInsecureMsg(p *core.Printer) {
477387
p.Reset()
478388
p.WriteString("'.\n")
479389
}
480-
481-
type errNoInferFilePath struct{}
482-
483-
func (err errNoInferFilePath) Error() string {
484-
return "unable to infer a file name for the output\n\nTo specify an exact path, try '--output <PATH>'"
485-
}
486-
487-
func (err errNoInferFilePath) PrintTo(p *core.Printer) {
488-
p.WriteString("unable to infer a file name for the output\n\n")
489-
490-
p.WriteString("To specify an exact path, try '")
491-
p.Set(core.Bold)
492-
p.WriteString("--output")
493-
p.Reset()
494-
p.WriteString(" <PATH>")
495-
p.WriteString("'")
496-
}

‎internal/fetch/output.go‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package fetch
2+
3+
import (
4+
"io"
5+
"mime"
6+
"net/http"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
11+
"github.com/ryanfowler/fetch/internal/core"
12+
)
13+
14+
func writeOutputToFile(filename string, body io.Reader, size int64, p *core.Printer, v core.Verbosity) error {
15+
name, err := filepath.Abs(filename)
16+
if err != nil {
17+
return err
18+
}
19+
20+
dir := filepath.Dir(name)
21+
base := filepath.Base(name)
22+
f, err := os.CreateTemp(dir, base+".*.download")
23+
if err != nil {
24+
return err
25+
}
26+
27+
// Optionally show a progress bar/spinner on stderr.
28+
if v > core.VSilent {
29+
if core.IsStderrTerm {
30+
if size > 0 {
31+
pb := newProgressBar(body, p, size)
32+
defer func() { pb.Close(name, err) }()
33+
body = pb
34+
} else {
35+
ps := newProgressSpinner(body, p)
36+
defer func() { ps.Close(name, err) }()
37+
body = ps
38+
}
39+
} else {
40+
ps := newProgressStatic(body, p)
41+
defer func() { ps.Close(name, err) }()
42+
body = ps
43+
}
44+
}
45+
46+
if _, err = io.Copy(f, body); err != nil {
47+
f.Close()
48+
return err
49+
}
50+
if err = f.Close(); err != nil {
51+
return err
52+
}
53+
54+
err = os.Rename(f.Name(), filename)
55+
return err
56+
57+
}
58+
59+
func getOutputValue(r *Request, hdrs http.Header) (string, error) {
60+
if r.Output != "" {
61+
// Output was provided directly.
62+
return r.Output, nil
63+
}
64+
if !r.OutputDir {
65+
// Remote output option wasn't provided, return an empty string.
66+
return "", nil
67+
}
68+
69+
// Attempt to get filename from the Content-Disposition header first.
70+
cdName := getContentDispositionFilename(hdrs)
71+
if cdName != "" {
72+
return cdName, nil
73+
}
74+
75+
// Get the final path component as the file name.
76+
path := r.URL.Path
77+
if !strings.HasPrefix(path, "/") {
78+
path = "/" + path
79+
}
80+
for path != "" {
81+
var after string
82+
path, after, _ = cutLast(path, "/")
83+
if after != "" {
84+
return after, nil
85+
}
86+
87+
}
88+
89+
// Fallback to the hostname as the file path and emit a warning.
90+
host := r.URL.Hostname()
91+
if host != "" {
92+
return host, nil
93+
}
94+
95+
return "", errNoInferFilePath{}
96+
}
97+
98+
func getContentDispositionFilename(hdrs http.Header) string {
99+
cd := hdrs.Get("Content-Disposition")
100+
if cd == "" {
101+
return ""
102+
}
103+
104+
_, params, err := mime.ParseMediaType(cd)
105+
if err != nil {
106+
return ""
107+
}
108+
109+
return params["filename"]
110+
}
111+
112+
func cutLast(s, sep string) (string, string, bool) {
113+
idx := strings.LastIndex(s, sep)
114+
if idx < 0 {
115+
return s, "", false
116+
}
117+
return s[:idx], s[idx+1:], true
118+
}
119+
120+
type errNoInferFilePath struct{}
121+
122+
func (err errNoInferFilePath) Error() string {
123+
return "unable to infer a file name for the output\n\nTo specify an exact path, try '--output <PATH>'"
124+
}
125+
126+
func (err errNoInferFilePath) PrintTo(p *core.Printer) {
127+
p.WriteString("unable to infer a file name for the output\n\n")
128+
129+
p.WriteString("To specify an exact path, try '")
130+
p.Set(core.Bold)
131+
p.WriteString("--output")
132+
p.Reset()
133+
p.WriteString(" <PATH>")
134+
p.WriteString("'")
135+
}

‎internal/fetch/progress.go‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,37 @@ func (ps *progressSpinner) render() {
245245
p.Flush()
246246
}
247247

248+
type progressStatic struct {
249+
r io.Reader
250+
printer *core.Printer
251+
bytesRead int64
252+
start time.Time
253+
}
254+
255+
func newProgressStatic(r io.Reader, p *core.Printer) *progressStatic {
256+
return &progressStatic{
257+
r: r,
258+
printer: p,
259+
start: time.Now(),
260+
}
261+
}
262+
263+
func (ps *progressStatic) Read(p []byte) (int, error) {
264+
n, err := ps.r.Read(p)
265+
ps.bytesRead += int64(n)
266+
return n, err
267+
}
268+
269+
func (ps *progressStatic) Close(path string, err error) {
270+
if err != nil {
271+
return
272+
}
273+
274+
dur := time.Since(ps.start)
275+
writeFinalProgress(ps.printer, ps.bytesRead, dur, -1, path)
276+
ps.printer.Flush()
277+
}
278+
248279
// formatSize converts bytes to a human-readable string.
249280
func formatSize(bytes int64) string {
250281
const units = "KMGTPE"
@@ -278,7 +309,11 @@ func formatDuration(d time.Duration) string {
278309
}
279310

280311
func writeFinalProgress(p *core.Printer, bytesRead int64, dur time.Duration, toClear int, path string) {
281-
p.WriteString("\rDownloaded ")
312+
if toClear >= 0 {
313+
p.WriteString("\r")
314+
}
315+
316+
p.WriteString("Downloaded ")
282317
p.Set(core.Bold)
283318
p.WriteString(formatSize(bytesRead))
284319
p.Reset()

0 commit comments

Comments
 (0)