autumn-cli 0.1.0

CLI tool for the Autumn web framework
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
//! Tailwind CSS CLI download and verification for `autumn setup`.
//!
//! Downloads the correct platform-specific Tailwind CSS v4.1.0 standalone binary,
//! verifies its SHA-256 checksum against the `sha256sums.txt` file published with
//! each release, and installs it to `target/autumn/tailwindcss`.

use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};

use indicatif::{ProgressBar, ProgressStyle};
use sha2::{Digest, Sha256};

/// Pinned Tailwind CSS release version.
const TAILWIND_VERSION: &str = "v4.1.0";

/// Base URL for Tailwind CSS release assets.
const RELEASE_BASE_URL: &str = "https://github.com/tailwindlabs/tailwindcss/releases/download";

// ── Errors ──────────────────────────────────────────────────────────────

/// Errors that can occur during the setup process.
#[derive(Debug, thiserror::Error)]
pub enum SetupError {
    /// The current OS/architecture combination is not supported.
    #[error("unsupported platform: os={0}, arch={1}")]
    UnsupportedPlatform(String, String),

    /// A network request failed.
    #[error("download failed: {0}")]
    Download(#[from] reqwest::Error),

    /// The downloaded binary does not match its expected checksum.
    #[error("checksum mismatch: expected {expected}, got {actual}")]
    ChecksumMismatch {
        /// The checksum we expected (from `sha256sums.txt`).
        expected: String,
        /// The checksum we actually computed.
        actual: String,
    },

    /// An I/O operation failed.
    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error),

    /// Failed to parse `sha256sums.txt`.
    #[error("failed to parse checksum file: {0}")]
    ChecksumParse(String),
}

// ── Public entry point ──────────────────────────────────────────────────

/// Run the `autumn setup` subcommand.
///
/// Downloads Tailwind CSS to `target/autumn/tailwindcss` (or `.exe` on Windows).
/// If the binary already exists and `force` is false, exits early.
pub fn run(force: bool) {
    if let Err(e) = execute(force) {
        eprintln!("Error: {e}");
        std::process::exit(1);
    }
}

/// Inner implementation so tests can call this without `process::exit`.
fn execute(force: bool) -> Result<(), SetupError> {
    let binary_name = detect_platform(std::env::consts::OS, std::env::consts::ARCH)?;
    let install_dir = PathBuf::from("target/autumn");
    let dest = install_path(&install_dir);

    if !force && dest.exists() {
        println!("Tailwind CLI already installed at {}", dest.display());
        return Ok(());
    }

    fs::create_dir_all(&install_dir)?;

    let download_url = format!("{RELEASE_BASE_URL}/{TAILWIND_VERSION}/{binary_name}");
    let checksums_url = format!("{RELEASE_BASE_URL}/{TAILWIND_VERSION}/sha256sums.txt");

    println!("Downloading Tailwind CSS {TAILWIND_VERSION} ({binary_name})...");

    // Fetch the expected checksum first (small file).
    let expected_hash = fetch_expected_checksum(&checksums_url, &binary_name)?;

    // Download the binary to a temporary file in the same directory.
    let tmp_path = install_dir.join(".tailwindcss.tmp");
    download_with_progress(&download_url, &tmp_path)?;

    // Verify integrity.
    let actual_hash = sha256_file(&tmp_path)?;
    verify_checksum(&expected_hash, &actual_hash)?;

    // Atomic-ish move: rename temp file to final destination.
    fs::rename(&tmp_path, &dest)?;

    // Set executable permissions on Unix.
    #[cfg(unix)]
    set_executable(&dest)?;

    println!("Tailwind CLI installed to {}", dest.display());
    Ok(())
}

// ── Platform detection ──────────────────────────────────────────────────

/// Return the Tailwind release asset name for the given OS and architecture.
///
/// Accepts the values produced by `std::env::consts::OS` and
/// `std::env::consts::ARCH` so that unit tests can inject arbitrary strings.
pub fn detect_platform(os: &str, arch: &str) -> Result<String, SetupError> {
    let platform = match (os, arch) {
        ("linux", "x86_64") => "tailwindcss-linux-x64",
        ("linux", "aarch64") => "tailwindcss-linux-arm64",
        ("macos", "x86_64") => "tailwindcss-macos-x64",
        ("macos", "aarch64") => "tailwindcss-macos-arm64",
        ("windows", "x86_64") => "tailwindcss-windows-x64.exe",
        _ => {
            return Err(SetupError::UnsupportedPlatform(
                os.to_owned(),
                arch.to_owned(),
            ));
        }
    };
    Ok(platform.to_owned())
}

/// Return the final install path for the Tailwind binary.
fn install_path(dir: &Path) -> PathBuf {
    if cfg!(windows) {
        dir.join("tailwindcss.exe")
    } else {
        dir.join("tailwindcss")
    }
}

// ── Checksum helpers ────────────────────────────────────────────────────

/// Download `sha256sums.txt` and extract the hex digest for `binary_name`.
fn fetch_expected_checksum(url: &str, binary_name: &str) -> Result<String, SetupError> {
    let body = reqwest::blocking::get(url)?.error_for_status()?.text()?;
    parse_checksum_file(&body, binary_name)
}

/// Parse a `sha256sums.txt` file and return the lowercase hex digest for `binary_name`.
///
/// The file contains lines of the form `<hex_digest>  <filename>`.
pub fn parse_checksum_file(body: &str, binary_name: &str) -> Result<String, SetupError> {
    for line in body.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        let mut parts = line.split_whitespace();
        let hash_part = parts.next().unwrap_or_default();
        let file_part = parts.next().unwrap_or_default();

        let file_part = file_part.strip_prefix("./").unwrap_or(file_part);
        if file_part == binary_name {
            if hash_part.len() != 64 || !hash_part.chars().all(|c| c.is_ascii_hexdigit()) {
                return Err(SetupError::ChecksumParse(format!(
                    "expected 64-char hex digest, got: {hash_part}"
                )));
            }
            return Ok(hash_part.to_ascii_lowercase());
        }
    }

    Err(SetupError::ChecksumParse(format!(
        "no checksum found for {binary_name}"
    )))
}

/// Compute the SHA-256 digest of a file and return it as a lowercase hex string.
pub fn sha256_file(path: &Path) -> Result<String, SetupError> {
    let data = fs::read(path)?;
    Ok(sha256_bytes(&data))
}

/// Compute the SHA-256 digest of a byte slice and return it as a lowercase hex string.
pub fn sha256_bytes(data: &[u8]) -> String {
    let digest = Sha256::digest(data);
    hex::encode(digest)
}

/// Compare expected vs actual checksums, returning an error on mismatch.
pub fn verify_checksum(expected: &str, actual: &str) -> Result<(), SetupError> {
    if expected == actual {
        Ok(())
    } else {
        Err(SetupError::ChecksumMismatch {
            expected: expected.to_owned(),
            actual: actual.to_owned(),
        })
    }
}

// ── Download with progress ──────────────────────────────────────────────

/// Download `url` to `dest`, showing a progress bar on stdout.
fn download_with_progress(url: &str, dest: &Path) -> Result<(), SetupError> {
    let response = reqwest::blocking::Client::new()
        .get(url)
        .send()?
        .error_for_status()?;

    let total = response.content_length().unwrap_or(0);
    let pb = ProgressBar::new(total);
    pb.set_style(
        ProgressStyle::with_template("  [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})")
            .expect("valid progress template")
            .progress_chars("=> "),
    );

    let mut file = fs::File::create(dest)?;
    let bytes = response.bytes()?;
    pb.set_length(bytes.len() as u64);
    file.write_all(&bytes)?;
    pb.finish_and_clear();
    Ok(())
}

// ── Unix permissions ────────────────────────────────────────────────────

/// Set the executable bit on a file (Unix only).
#[cfg(unix)]
fn set_executable(path: &Path) -> Result<(), SetupError> {
    use std::os::unix::fs::PermissionsExt;
    let mut perms = fs::metadata(path)?.permissions();
    let mode = perms.mode() | 0o111;
    perms.set_mode(mode);
    fs::set_permissions(path, perms)?;
    Ok(())
}

// ── Tests ───────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    // ── Platform detection ──────────────────────────────────────────

    #[test]
    fn detect_linux_x64() {
        let name = detect_platform("linux", "x86_64").expect("should be supported");
        assert_eq!(name, "tailwindcss-linux-x64");
    }

    #[test]
    fn detect_linux_arm64() {
        let name = detect_platform("linux", "aarch64").expect("should be supported");
        assert_eq!(name, "tailwindcss-linux-arm64");
    }

    #[test]
    fn detect_macos_x64() {
        let name = detect_platform("macos", "x86_64").expect("should be supported");
        assert_eq!(name, "tailwindcss-macos-x64");
    }

    #[test]
    fn detect_macos_arm64() {
        let name = detect_platform("macos", "aarch64").expect("should be supported");
        assert_eq!(name, "tailwindcss-macos-arm64");
    }

    #[test]
    fn detect_windows_x64() {
        let name = detect_platform("windows", "x86_64").expect("should be supported");
        assert_eq!(name, "tailwindcss-windows-x64.exe");
    }

    #[test]
    fn detect_unsupported_os() {
        let err = detect_platform("freebsd", "x86_64").unwrap_err();
        assert!(matches!(err, SetupError::UnsupportedPlatform(_, _)));
        assert!(err.to_string().contains("freebsd"));
    }

    #[test]
    fn detect_unsupported_arch() {
        let err = detect_platform("linux", "riscv64").unwrap_err();
        assert!(matches!(err, SetupError::UnsupportedPlatform(_, _)));
        assert!(err.to_string().contains("riscv64"));
    }

    // ── Checksum verification ───────────────────────────────────────

    #[test]
    fn sha256_known_value() {
        // SHA-256 of the empty string.
        let hash = sha256_bytes(b"");
        assert_eq!(
            hash,
            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
        );
    }

    #[test]
    fn sha256_hello_world() {
        let hash = sha256_bytes(b"hello world\n");
        assert_eq!(
            hash,
            "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"
        );
    }

    #[test]
    fn verify_checksum_match() {
        let hash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
        assert!(verify_checksum(hash, hash).is_ok());
    }

    #[test]
    fn verify_checksum_mismatch() {
        let expected = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
        let actual = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
        let err = verify_checksum(expected, actual).unwrap_err();
        assert!(matches!(err, SetupError::ChecksumMismatch { .. }));
        assert!(err.to_string().contains(expected));
        assert!(err.to_string().contains(actual));
    }

    // ── Checksum file parsing ───────────────────────────────────────

    #[test]
    fn parse_finds_correct_binary() {
        // Real format uses `./` prefix on filenames.
        let body = "\
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa  ./tailwindcss-linux-x64
a948904f2f0f479b8f8564e9d7a8f22e32d13e73845f1b0ea0e2975a02c8b87f  ./tailwindcss-windows-x64.exe
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb  ./tailwindcss-macos-arm64
";
        let hash = parse_checksum_file(body, "tailwindcss-windows-x64.exe").expect("should parse");
        assert_eq!(
            hash,
            "a948904f2f0f479b8f8564e9d7a8f22e32d13e73845f1b0ea0e2975a02c8b87f"
        );
    }

    #[test]
    fn parse_works_without_prefix() {
        // Also handle the no-prefix case for robustness.
        let body = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa  tailwindcss-linux-x64\n";
        let hash = parse_checksum_file(body, "tailwindcss-linux-x64").expect("should parse");
        assert_eq!(
            hash,
            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
        );
    }

    #[test]
    fn parse_uppercase_hex() {
        let body = "A948904F2F0F479B8F8564E9D7A8F22E32D13E73845F1B0EA0E2975A02C8B87F  tailwindcss-linux-x64\n";
        let hash = parse_checksum_file(body, "tailwindcss-linux-x64").expect("should parse");
        assert_eq!(
            hash,
            "a948904f2f0f479b8f8564e9d7a8f22e32d13e73845f1b0ea0e2975a02c8b87f"
        );
    }

    #[test]
    fn parse_empty_file_fails() {
        let err = parse_checksum_file("", "tailwindcss-linux-x64").unwrap_err();
        assert!(matches!(err, SetupError::ChecksumParse(_)));
    }

    #[test]
    fn parse_missing_binary_fails() {
        let body = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa  tailwindcss-linux-x64\n";
        let err = parse_checksum_file(body, "tailwindcss-windows-x64.exe").unwrap_err();
        assert!(matches!(err, SetupError::ChecksumParse(_)));
        assert!(err.to_string().contains("tailwindcss-windows-x64.exe"));
    }

    #[test]
    fn parse_non_hex_fails() {
        let body = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz  tailwindcss-linux-x64\n";
        let err = parse_checksum_file(body, "tailwindcss-linux-x64").unwrap_err();
        assert!(matches!(err, SetupError::ChecksumParse(_)));
    }

    // ── File-based checksum ─────────────────────────────────────────

    #[test]
    fn sha256_file_matches_bytes() {
        let tmp = tempfile::NamedTempFile::new().expect("create temp file");
        fs::write(tmp.path(), b"test data").expect("write temp file");
        let file_hash = sha256_file(tmp.path()).expect("hash file");
        let byte_hash = sha256_bytes(b"test data");
        assert_eq!(file_hash, byte_hash);
    }

    // ── Install path ────────────────────────────────────────────────

    #[test]
    fn install_path_is_correct() {
        let dir = Path::new("target/autumn");
        let path = install_path(dir);
        if cfg!(windows) {
            assert_eq!(path, PathBuf::from("target/autumn/tailwindcss.exe"));
        } else {
            assert_eq!(path, PathBuf::from("target/autumn/tailwindcss"));
        }
    }

    // ── Integration test (requires network) ─────────────────────────

    #[test]
    #[ignore = "requires network access to download Tailwind binary"]
    fn download_and_verify_tailwind() {
        let tmp = tempfile::TempDir::new().expect("create temp dir");
        let install_dir = tmp.path().join("target/autumn");
        fs::create_dir_all(&install_dir).expect("create install dir");

        let binary_name = detect_platform(std::env::consts::OS, std::env::consts::ARCH)
            .expect("supported platform");

        let download_url = format!("{RELEASE_BASE_URL}/{TAILWIND_VERSION}/{binary_name}");
        let checksums_url = format!("{RELEASE_BASE_URL}/{TAILWIND_VERSION}/sha256sums.txt");

        let expected_hash =
            fetch_expected_checksum(&checksums_url, &binary_name).expect("fetch checksum");

        let dest = install_dir.join(".tailwindcss.tmp");
        download_with_progress(&download_url, &dest).expect("download binary");

        let actual_hash = sha256_file(&dest).expect("hash file");
        verify_checksum(&expected_hash, &actual_hash).expect("checksum match");

        // Binary should be non-empty.
        let meta = fs::metadata(&dest).expect("metadata");
        assert!(
            meta.len() > 1_000_000,
            "binary too small: {} bytes",
            meta.len()
        );
    }
}