Skip to content

Commit e09175d

Browse files
jason-rlclaude
andcommitted
feat: add inferDownloadExtension and getDefaultDownloadPath utilities
Add shared utilities for inferring file extensions from object content types when generating default download paths. This is the complement of adjustFileExtension() in runloop-fe (PR #1714), which strips extensions for mount paths — this module adds extensions for download paths. Rules: - text/binary with no suffix → .txt/.bin - gzip + .tar suffix → .tgz (not .tar.gz) - gzip/tar/tgz with mismatched suffix → append correct extension - All suffix checks are case-insensitive Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3528701 commit e09175d

2 files changed

Lines changed: 326 additions & 0 deletions

File tree

src/utils/downloadPath.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/**
2+
* Utilities for inferring download file extensions from content types
3+
* and generating default download paths for objects.
4+
*
5+
* This is the complement of adjustFileExtension() in runloop-fe's
6+
* object-mount-utils.ts, which strips extensions for mount paths
7+
* (after decompression/extraction). This module adds extensions for
8+
* download paths so the saved file reflects the object's content type.
9+
*/
10+
11+
/** Suffixes considered "already gzip" (case-insensitive) */
12+
const GZIP_SUFFIXES = new Set([".gz", ".gzip", ".taz", ".tgz"]);
13+
14+
/** Suffixes considered "already tgz" (case-insensitive) */
15+
const TGZ_SUFFIXES = new Set([".taz", ".tgz"]);
16+
17+
/**
18+
* Check if name ends with a compound suffix like .tar.gz or .tar.gzip
19+
* (case-insensitive). Returns true if the last two dot-segments match.
20+
*/
21+
function hasCompoundTgzSuffix(name: string): boolean {
22+
return /\.(tar\.gz|tar\.gzip)$/i.test(name);
23+
}
24+
25+
/**
26+
* Get the suffix of a filename (the part after the last dot).
27+
* Returns empty string if no dot or only a leading dot (e.g. ".hidden").
28+
*/
29+
function getSuffix(name: string): string {
30+
const lastDot = name.lastIndexOf(".");
31+
if (lastDot <= 0) return "";
32+
return name.slice(lastDot);
33+
}
34+
35+
/**
36+
* Returns true if the name has any dot-separated extension.
37+
* A leading dot alone (e.g. ".hidden") does not count as having a suffix.
38+
*/
39+
function hasSuffix(name: string): boolean {
40+
return getSuffix(name) !== "";
41+
}
42+
43+
/**
44+
* Infer a download filename by appending or adjusting the file extension
45+
* based on the object's content_type.
46+
*
47+
* Rules (suffix comparisons are case-insensitive):
48+
* - text + no suffix → .txt
49+
* - binary + no suffix → .bin
50+
* - gzip + suffix is .tar → replace with .tgz
51+
* - gzip + suffix not in {.gz,.gzip,.taz,.tgz,.tar} → append .gz
52+
* - tar + suffix != .tar → append .tar
53+
* - tgz + suffix not in {.tar.gz,.tar.gzip,.taz,.tgz} → append .tgz
54+
* - unspecified / undefined → no change
55+
*/
56+
export function inferDownloadExtension(
57+
name: string,
58+
contentType: string | undefined,
59+
): string {
60+
if (!contentType || contentType === "unspecified") return name;
61+
62+
const suffix = getSuffix(name).toLowerCase();
63+
64+
switch (contentType) {
65+
case "text":
66+
return hasSuffix(name) ? name : `${name}.txt`;
67+
68+
case "binary":
69+
return hasSuffix(name) ? name : `${name}.bin`;
70+
71+
case "gzip":
72+
if (suffix === ".tar") {
73+
// gzipped tar → .tgz
74+
return name.slice(0, -suffix.length) + ".tgz";
75+
}
76+
if (GZIP_SUFFIXES.has(suffix)) return name;
77+
return `${name}.gz`;
78+
79+
case "tar":
80+
if (suffix === ".tar") return name;
81+
return `${name}.tar`;
82+
83+
case "tgz":
84+
if (hasCompoundTgzSuffix(name)) return name;
85+
if (TGZ_SUFFIXES.has(suffix)) return name;
86+
return `${name}.tgz`;
87+
88+
default:
89+
return name;
90+
}
91+
}
92+
93+
/**
94+
* Generate a default download path for an object.
95+
*
96+
* Uses the object's name (or ID as fallback), applies extension inference,
97+
* and prepends "./" for a relative path.
98+
*/
99+
export function getDefaultDownloadPath(
100+
name: string | undefined,
101+
id: string,
102+
contentType: string | undefined,
103+
): string {
104+
const baseName = name?.trim() || id;
105+
const withExtension = inferDownloadExtension(baseName, contentType);
106+
return `./${withExtension}`;
107+
}
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
/**
2+
* Tests for download path utilities
3+
*/
4+
5+
import { describe, it, expect } from "@jest/globals";
6+
import {
7+
inferDownloadExtension,
8+
getDefaultDownloadPath,
9+
} from "../../../src/utils/downloadPath.js";
10+
11+
describe("inferDownloadExtension", () => {
12+
describe("no suffix on name", () => {
13+
it("appends .txt for text content type", () => {
14+
expect(inferDownloadExtension("myfile", "text")).toBe("myfile.txt");
15+
});
16+
17+
it("appends .bin for binary content type", () => {
18+
expect(inferDownloadExtension("myfile", "binary")).toBe("myfile.bin");
19+
});
20+
21+
it("appends .gz for gzip content type", () => {
22+
expect(inferDownloadExtension("myfile", "gzip")).toBe("myfile.gz");
23+
});
24+
25+
it("appends .tar for tar content type", () => {
26+
expect(inferDownloadExtension("myfile", "tar")).toBe("myfile.tar");
27+
});
28+
29+
it("appends .tgz for tgz content type", () => {
30+
expect(inferDownloadExtension("myfile", "tgz")).toBe("myfile.tgz");
31+
});
32+
33+
it("appends .txt for dot-only hidden files with text type", () => {
34+
expect(inferDownloadExtension(".hidden", "text")).toBe(".hidden.txt");
35+
});
36+
});
37+
38+
describe("suffix matches content type (no change)", () => {
39+
it("keeps .gz for gzip", () => {
40+
expect(inferDownloadExtension("myfile.gz", "gzip")).toBe("myfile.gz");
41+
});
42+
43+
it("keeps .gzip for gzip", () => {
44+
expect(inferDownloadExtension("myfile.gzip", "gzip")).toBe(
45+
"myfile.gzip",
46+
);
47+
});
48+
49+
it("keeps .taz for gzip", () => {
50+
expect(inferDownloadExtension("file.taz", "gzip")).toBe("file.taz");
51+
});
52+
53+
it("keeps .tgz for gzip", () => {
54+
expect(inferDownloadExtension("file.tgz", "gzip")).toBe("file.tgz");
55+
});
56+
57+
it("keeps .tar for tar", () => {
58+
expect(inferDownloadExtension("myfile.tar", "tar")).toBe("myfile.tar");
59+
});
60+
61+
it("keeps .tgz for tgz", () => {
62+
expect(inferDownloadExtension("myfile.tgz", "tgz")).toBe("myfile.tgz");
63+
});
64+
65+
it("keeps .taz for tgz", () => {
66+
expect(inferDownloadExtension("myfile.taz", "tgz")).toBe("myfile.taz");
67+
});
68+
69+
it("keeps .tar.gz for tgz", () => {
70+
expect(inferDownloadExtension("myfile.tar.gz", "tgz")).toBe(
71+
"myfile.tar.gz",
72+
);
73+
});
74+
75+
it("keeps .tar.gzip for tgz", () => {
76+
expect(inferDownloadExtension("data.tar.gzip", "tgz")).toBe(
77+
"data.tar.gzip",
78+
);
79+
});
80+
});
81+
82+
describe("suffix mismatches content type (appends)", () => {
83+
it("appends .gz for gzip when suffix is .txt", () => {
84+
expect(inferDownloadExtension("myfile.txt", "gzip")).toBe(
85+
"myfile.txt.gz",
86+
);
87+
});
88+
89+
it("appends .tar for tar when suffix is .json", () => {
90+
expect(inferDownloadExtension("myfile.json", "tar")).toBe(
91+
"myfile.json.tar",
92+
);
93+
});
94+
95+
it("appends .tgz for tgz when suffix is .bin", () => {
96+
expect(inferDownloadExtension("myfile.bin", "tgz")).toBe(
97+
"myfile.bin.tgz",
98+
);
99+
});
100+
});
101+
102+
describe("gzip + .tar special case", () => {
103+
it("replaces .tar with .tgz for gzip content type", () => {
104+
expect(inferDownloadExtension("archive.tar", "gzip")).toBe(
105+
"archive.tgz",
106+
);
107+
});
108+
109+
it("replaces .TAR with .tgz for gzip content type (case-insensitive)", () => {
110+
expect(inferDownloadExtension("archive.TAR", "gzip")).toBe(
111+
"archive.tgz",
112+
);
113+
});
114+
});
115+
116+
describe("text/binary with existing suffix (no change)", () => {
117+
it("keeps .json for text type", () => {
118+
expect(inferDownloadExtension("myfile.json", "text")).toBe("myfile.json");
119+
});
120+
121+
it("keeps .yaml for text type", () => {
122+
expect(inferDownloadExtension("config.yaml", "text")).toBe("config.yaml");
123+
});
124+
125+
it("keeps .wasm for binary type", () => {
126+
expect(inferDownloadExtension("myfile.wasm", "binary")).toBe(
127+
"myfile.wasm",
128+
);
129+
});
130+
131+
it("keeps .exe for binary type", () => {
132+
expect(inferDownloadExtension("app.exe", "binary")).toBe("app.exe");
133+
});
134+
});
135+
136+
describe("case insensitivity", () => {
137+
it("recognizes .GZ as matching gzip", () => {
138+
expect(inferDownloadExtension("myfile.GZ", "gzip")).toBe("myfile.GZ");
139+
});
140+
141+
it("recognizes .TAR as matching tar", () => {
142+
expect(inferDownloadExtension("myfile.TAR", "tar")).toBe("myfile.TAR");
143+
});
144+
145+
it("recognizes .Tgz as matching tgz", () => {
146+
expect(inferDownloadExtension("myfile.Tgz", "tgz")).toBe("myfile.Tgz");
147+
});
148+
149+
it("recognizes .TAR.GZIP as matching tgz", () => {
150+
expect(inferDownloadExtension("data.TAR.GZIP", "tgz")).toBe(
151+
"data.TAR.GZIP",
152+
);
153+
});
154+
155+
it("recognizes .TAR.GZ as matching tgz", () => {
156+
expect(inferDownloadExtension("data.TAR.GZ", "tgz")).toBe("data.TAR.GZ");
157+
});
158+
159+
it("recognizes .Taz as matching tgz", () => {
160+
expect(inferDownloadExtension("data.Taz", "tgz")).toBe("data.Taz");
161+
});
162+
});
163+
164+
describe("edge cases", () => {
165+
it("returns name unchanged for unspecified content type", () => {
166+
expect(inferDownloadExtension("myfile", "unspecified")).toBe("myfile");
167+
});
168+
169+
it("returns name unchanged for undefined content type", () => {
170+
expect(inferDownloadExtension("myfile", undefined)).toBe("myfile");
171+
});
172+
173+
it("returns name unchanged for empty string content type", () => {
174+
expect(inferDownloadExtension("myfile", "")).toBe("myfile");
175+
});
176+
});
177+
});
178+
179+
describe("getDefaultDownloadPath", () => {
180+
it("uses name with extension inference", () => {
181+
expect(getDefaultDownloadPath("myfile", "obj_123", "text")).toBe(
182+
"./myfile.txt",
183+
);
184+
});
185+
186+
it("falls back to id when name is undefined", () => {
187+
expect(getDefaultDownloadPath(undefined, "obj_123", "text")).toBe(
188+
"./obj_123.txt",
189+
);
190+
});
191+
192+
it("falls back to id when name is empty", () => {
193+
expect(getDefaultDownloadPath("", "obj_123", "tar")).toBe("./obj_123.tar");
194+
});
195+
196+
it("falls back to id when name is whitespace", () => {
197+
expect(getDefaultDownloadPath(" ", "obj_123", "binary")).toBe(
198+
"./obj_123.bin",
199+
);
200+
});
201+
202+
it("trims name before processing", () => {
203+
expect(getDefaultDownloadPath(" myfile ", "obj_123", "gzip")).toBe(
204+
"./myfile.gz",
205+
);
206+
});
207+
208+
it("preserves existing matching extension", () => {
209+
expect(getDefaultDownloadPath("data.tar.gz", "obj_123", "tgz")).toBe(
210+
"./data.tar.gz",
211+
);
212+
});
213+
214+
it("handles no content type", () => {
215+
expect(getDefaultDownloadPath("myfile", "obj_123", undefined)).toBe(
216+
"./myfile",
217+
);
218+
});
219+
});

0 commit comments

Comments
 (0)