From 1e3bd82ae72861aa2133a1c7a6d99816032e5c63 Mon Sep 17 00:00:00 2001 From: Caileb Date: Thu, 19 Jun 2025 10:38:25 -0500 Subject: [PATCH] Major rewrite, automatic path, smaller binary --- Cargo.lock | 333 +--------------------------- Cargo.toml | 27 ++- README.md | 15 +- src/main.rs | 625 +++++++++++++++++++++++++++++++--------------------- 4 files changed, 414 insertions(+), 586 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e6eb826..da6dd32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,62 +17,6 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" -[[package]] -name = "anstream" -version = "0.6.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" - -[[package]] -name = "anstyle-parse" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" -dependencies = [ - "windows-sys 0.59.0", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6680de5231bd6ee4c6191b8a1325daa282b415391ec9d3a37bd34f2060dc73fa" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.59.0", -] - -[[package]] -name = "anyhow" -version = "1.0.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" - [[package]] name = "backtrace" version = "0.3.75" @@ -85,15 +29,9 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-targets 0.52.6", + "windows-targets", ] -[[package]] -name = "bitflags" -version = "2.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" - [[package]] name = "bytes" version = "1.10.1" @@ -106,92 +44,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" -[[package]] -name = "clap" -version = "4.5.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd60e63e9be68e5fb56422e397cf9baddded06dae1d2e523401542383bc72a9f" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.5.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89cc6392a1f72bbeb820d71f32108f61fdaf18bc526e1d23954168a67759ef51" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.5.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" - -[[package]] -name = "colorchoice" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" - -[[package]] -name = "dirs" -version = "5.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.48.0", -] - -[[package]] -name = "env_filter" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" -dependencies = [ - "log", -] - -[[package]] -name = "env_logger" -version = "0.11.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" -dependencies = [ - "env_filter", - "log", -] - [[package]] name = "fnv" version = "1.0.7" @@ -231,29 +83,12 @@ dependencies = [ "pin-utils", ] -[[package]] -name = "getrandom" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - [[package]] name = "gimli" version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - [[package]] name = "http" version = "1.3.1" @@ -334,12 +169,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "is_terminal_polyfill" -version = "1.70.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" - [[package]] name = "itoa" version = "1.0.15" @@ -352,22 +181,6 @@ version = "0.2.172" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" -[[package]] -name = "libredox" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" -dependencies = [ - "bitflags", - "libc", -] - -[[package]] -name = "log" -version = "0.4.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" - [[package]] name = "memchr" version = "2.7.4" @@ -419,18 +232,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "once_cell_polyfill" -version = "1.70.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - [[package]] name = "percent-encoding" version = "2.3.1" @@ -462,14 +263,9 @@ dependencies = [ name = "quickstart" version = "0.1.0" dependencies = [ - "anyhow", - "clap", - "dirs", - "env_logger", "http-body-util", "hyper", "hyper-util", - "log", "mime_guess", "percent-encoding", "tokio", @@ -484,17 +280,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom", - "libredox", - "thiserror", -] - [[package]] name = "rustc-demangle" version = "0.1.24" @@ -526,12 +311,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - [[package]] name = "syn" version = "2.0.101" @@ -543,26 +322,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "tokio" version = "1.45.1" @@ -602,34 +361,19 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -638,22 +382,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", + "windows-targets", ] [[package]] @@ -662,46 +391,28 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -714,48 +425,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index 07942c4..2ab4b48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,24 +1,27 @@ [package] name = "quickstart" -version = "0.1.0" +version = "0.2.0" edition = "2024" [dependencies] hyper = { version = "1", features = ["http1", "server"], default-features = false } hyper-util = { version = "0.1", features = ["tokio"], default-features = false } -tokio = { version = "1", features = ["rt", "fs", "net", "signal", "macros"], default-features = false } -clap = { version = "4", features = ["derive", "cargo", "std"] } -anyhow = "1.0" -log = "0.4" -env_logger = { version = "0.11", default-features = false } +tokio = { version = "1", features = ["rt", "rt-multi-thread", "fs", "net", "signal", "macros"], default-features = false } mime_guess = { version = "2.0", default-features = false } percent-encoding = "2.3" http-body-util = { version = "0.1", default-features = false } -dirs = "5.0" [profile.release] -lto = true -codegen-units = 1 -strip = true -opt-level = "z" -panic = "abort" \ No newline at end of file +opt-level = "z" # Optimize aggressively for size +lto = true # Link-time optimization +codegen-units = 1 # Maximum optimization opportunities +panic = "abort" # Smaller panic handling +strip = true # Strip symbols (Rust 1.59+) +overflow-checks = false # Disable overflow checks for size +debug = false # No debug info +debug-assertions = false # No debug assertions +incremental = false # Disable incremental compilation +rpath = false # Don't use rpath + +[profile.release.package."*"] +opt-level = "z" # Apply size optimization to all dependencies \ No newline at end of file diff --git a/README.md b/README.md index 90a15b8..4373809 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A zero-configuration Rust CLI for instantly serving any local directory over HTT - Zero-configuration: serve the current directory immediately with sensible defaults - Flexible options to override directory, port, and bind address - Graceful shutdown on Ctrl+C -- Tiny release binary (<1MB) +- Tiny release binary (~650KB) ## About `quickstart` takes any local directory and serves it over HTTP ***instantly***, letting you: @@ -36,7 +36,9 @@ By default, this installs to: - **Windows**: `$env:USERPROFILE\.quickstart\bin` (same as double-click) ### Add to PATH -Ensure your local bin directory is in your PATH: +`quickstart` automatically attempts to add itself to your PATH on first run. If this succeeds, you'll see a confirmation message. **You may need to restart your terminal/IDE** for PATH changes to take effect. + +If automatic setup fails, manually ensure your local bin directory is in your PATH: **Windows (PowerShell)** ```powershell @@ -48,19 +50,22 @@ $env:Path += ";$env:USERPROFILE\.quickstart\bin" export PATH="$HOME/.quickstart/bin:$PATH" ``` -After updating, restart your terminal. - ## Usage ```bash quickstart [OPTIONS] ``` ## Examples -Serve the current directory on port 8080: +Serve the current directory on localhost port 8080: ```bash quickstart +# Serving C:\Users\Caileb\Downloads at http://127.0.0.1:8080 ``` Serve the `public` directory on port 3000: ```bash quickstart -d public -p 3000 +``` +Serve on all network interfaces (for LAN sharing): +```bash +quickstart -b 0.0.0.0 -p 8080 ``` \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 83179a7..7dcc03f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,414 +1,547 @@ -// To hide console on Windows for GUI (but makes CLI output complex): -// #![cfg_attr(all(not(debug_assertions), target_os = "windows"), windows_subsystem = "windows")] +#![forbid(unsafe_code)] +#![forbid(clippy::all)] + +use std::{ + convert::Infallible, + env, + fs, + io::{self, Write}, + net::{IpAddr, SocketAddr}, + path::{Component, Path, PathBuf}, + str::FromStr, + sync::Arc, +}; -use anyhow::{bail, Context, Result}; -use clap::Parser; -use http_body_util::Full; use hyper::{ - body::{Bytes, Incoming}, + body::Bytes, header::{CONTENT_TYPE, LOCATION}, server::conn::http1, service::service_fn, Method, Request, Response, StatusCode, }; use hyper_util::rt::TokioIo; -use log::{debug, error, info, warn}; use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS}; -use std::{ - convert::Infallible, - env, - fs as std_fs, - io::{Read, Write}, - net::{IpAddr, SocketAddr}, - path::{Component, Path, PathBuf}, - str::FromStr, - sync::Arc, -}; use tokio::net::TcpListener; -const APP_NAME: &str = clap::crate_name!(); -const VERSION: &str = clap::crate_version!(); +const APP_NAME: &str = "quickstart"; +const VERSION: &str = env!("CARGO_PKG_VERSION"); const FRAGMENT: &AsciiSet = &CONTROLS.add(b' ').add(b'"').add(b'<').add(b'>').add(b'`'); const PATH_SEGMENT: &AsciiSet = &FRAGMENT.add(b'%').add(b'/').add(b'?').add(b'#'); -// --- CLI Argument Parsing Setup --- -#[derive(Parser, Debug)] -#[clap(name = APP_NAME, version = VERSION, about = "A quick, simple HTTP file server. Auto-installs/updates on first run or version change.")] -struct Cli { - #[clap(subcommand)] - command: Option, +type Body = http_body_util::Full; +type Result = std::result::Result>; - #[clap(short, long, default_value = ".", global = true)] +#[derive(Debug)] +struct Config { dir: PathBuf, - - #[clap(short, long, default_value_t = 8080, global = true)] port: u16, - - #[clap(short, long, value_parser = parse_ip_addr, default_value_t = IpAddr::from_str("0.0.0.0").unwrap(), global = true)] bind: IpAddr, } -#[derive(clap::Subcommand, Debug)] -enum Commands { - /// (Re)Install this application to a local directory for easy PATH access - Install, +impl Default for Config { + fn default() -> Self { + Self { + dir: PathBuf::from("."), + port: 8080, + bind: IpAddr::from_str("127.0.0.1").unwrap(), + } + } } -fn parse_ip_addr(s: &str) -> Result { - IpAddr::from_str(s).map_err(|e| e.to_string()) +#[derive(Debug)] +enum ServeError { + NotFound, + Forbidden, + Io(io::Error), +} + +impl From for ServeError { + fn from(err: io::Error) -> Self { + match err.kind() { + io::ErrorKind::NotFound => Self::NotFound, + io::ErrorKind::PermissionDenied => Self::Forbidden, + _ => Self::Io(err), + } + } +} + +impl ServeError { + fn status_code(&self) -> StatusCode { + match self { + Self::NotFound => StatusCode::NOT_FOUND, + Self::Forbidden => StatusCode::FORBIDDEN, + Self::Io(_) => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn message(&self) -> String { + match self { + Self::NotFound => "Not Found".to_string(), + Self::Forbidden => "Forbidden".to_string(), + Self::Io(e) => format!("Internal Server Error: {}", e), + } + } } -// --- Installation Helper Functions --- fn get_install_paths() -> Result<(PathBuf, PathBuf, PathBuf)> { - let home_dir = dirs::home_dir().context("Failed to determine home directory.")?; - let install_dir = home_dir.join(format!(".{}", APP_NAME.to_lowercase())).join("bin"); - let exe_name = if cfg!(windows) { format!("{}.exe", APP_NAME) } else { APP_NAME.to_string() }; + let home_dir = if let Some(home) = env::var_os("HOME") { + PathBuf::from(home) + } else if let Some(userprofile) = env::var_os("USERPROFILE") { + PathBuf::from(userprofile) + } else { + return Err("Could not determine home directory".into()); + }; + + let install_dir = home_dir.join(format!(".{}", APP_NAME)).join("bin"); + let exe_name = if cfg!(windows) { + format!("{}.exe", APP_NAME) + } else { + APP_NAME.to_string() + }; let exe_path = install_dir.join(&exe_name); - let version_path = install_dir.join(format!("{}.version", APP_NAME.to_lowercase())); + let version_path = install_dir.join(format!("{}.version", APP_NAME)); + Ok((install_dir, exe_path, version_path)) } -fn read_version(path: &Path) -> Result> { - if !path.exists() { return Ok(None); } - let mut file = std_fs::File::open(path)?; - let mut version = String::new(); - file.read_to_string(&mut version)?; - Ok(Some(version.trim().to_string())) +fn read_version(path: &Path) -> Option { + fs::read_to_string(path).ok().map(|s| s.trim().to_string()) } fn write_version(path: &Path, version: &str) -> Result<()> { - std_fs::create_dir_all(path.parent().unwrap())?; - std_fs::File::create(path)?.write_all(version.as_bytes())?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, version)?; Ok(()) } fn install_app(current_exe: &Path, target_dir: &Path, target_exe: &Path, version_path: &Path) -> Result<()> { if current_exe == target_exe { - let installed_version = read_version(version_path)?; + let installed_version = read_version(version_path); if installed_version.as_deref() != Some(VERSION) { write_version(version_path, VERSION)?; - info!("Updated version file for executable at target location."); + println!("[{}] Updated version file to: {}", APP_NAME, VERSION); } return Ok(()); } - std_fs::create_dir_all(target_dir)?; - std_fs::copy(current_exe, target_exe)?; + fs::create_dir_all(target_dir)?; + fs::copy(current_exe, target_exe)?; write_version(version_path, VERSION)?; - info!("Copied executable to {} and wrote version: {}", target_exe.display(), VERSION); + println!("[{}] Installed to: {}", APP_NAME, target_exe.display()); Ok(()) } -fn print_path_instructions(target_dir: &Path, target_exe: &Path) { - println!("\n--- {} Installation/Update ---", APP_NAME.to_uppercase()); - println!("{} has been copied/updated to: {}", APP_NAME, target_exe.display()); - println!("\nIMPORTANT: For easy access, add this directory to your PATH:"); - println!(" {}", target_dir.display()); +fn add_to_path_instructions(target_dir: &Path) { + println!("\n=== Manual PATH Setup ==="); if cfg!(windows) { - println!("\nFor example:"); - println!(" - PowerShell (current session): $env:Path += \";{}\"", target_dir.display()); - println!(" - Command Prompt (current session): set PATH=%PATH%;{}", target_dir.display()); - println!(" - To make it permanent, search for 'environment variables' in Windows settings."); + println!("To add to PATH permanently on Windows:"); + println!("1. Search 'environment variables' in Windows settings"); + println!("2. Edit your user PATH variable"); + println!("3. Add: {}", target_dir.display()); + println!("4. RESTART your terminal/IDE"); + println!("\nOr for current session only (PowerShell):"); + println!(" $env:Path += \";{}\"", target_dir.display()); } else { - println!("\nFor example (add to ~/.bashrc or ~/.profile):"); + println!("Add this line to your ~/.bashrc or ~/.profile:"); println!(" export PATH=\"{}:$PATH\"", target_dir.display()); + println!("Then RESTART your terminal/IDE or run: source ~/.bashrc"); } - println!("\nOnce PATH is updated, you can run '{}' from any terminal.", APP_NAME); + + println!("\nOnce PATH is updated, run '{}' from anywhere!", APP_NAME); + println!("=========================="); } -// --- Server Error Type --- -#[derive(Debug)] -enum ServeError { - NotFound, - Forbidden, - Io(std::io::Error), -} - -impl From for ServeError { - fn from(err: std::io::Error) -> ServeError { - match err.kind() { - std::io::ErrorKind::NotFound => ServeError::NotFound, - std::io::ErrorKind::PermissionDenied => ServeError::Forbidden, - _ => ServeError::Io(err), +fn try_add_to_path_auto(target_dir: &Path) -> bool { + if cfg!(windows) { + // SAFE Windows PATH addition using PowerShell to read current PATH first + let target_str = target_dir.to_string_lossy(); + + // Use PowerShell to safely read and update PATH + let script = format!( + "$current = [Environment]::GetEnvironmentVariable('PATH', 'User'); \ + if ($current -and $current -like '*{}*') {{ \ + Write-Host 'Already in PATH' \ + }} else {{ \ + $new = if ($current) {{ \"$current;{}\" }} else {{ \"{}\" }}; \ + [Environment]::SetEnvironmentVariable('PATH', $new, 'User'); \ + Write-Host 'Added to PATH' \ + }}", + target_str, target_str, target_str + ); + + let output = std::process::Command::new("powershell") + .args(&["-Command", &script]) + .output(); + + if let Ok(output) = output { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + if stdout.contains("Added to PATH") { + println!("[{}] ✓ Added to Windows PATH!", APP_NAME); + println!(" RESTART your terminal/IDE for PATH changes to take effect"); + return true; + } else if stdout.contains("Already in PATH") { + println!("[{}] ✓ Already in Windows PATH", APP_NAME); + return true; + } + } + } + } else { + // Safe Linux/macOS PATH addition + if let Some(home) = env::var_os("HOME") { + let bashrc = PathBuf::from(home).join(".bashrc"); + if let Ok(content) = fs::read_to_string(&bashrc) { + let target_str = target_dir.to_string_lossy(); + if content.contains(&*target_str) { + println!("[{}] ✓ Already in ~/.bashrc PATH", APP_NAME); + return true; + } + + if let Ok(mut file) = fs::OpenOptions::new().append(true).open(&bashrc) { + let path_line = format!("export PATH=\"{}:$PATH\"", target_str); + if writeln!(file, "\n# Added by {}", APP_NAME).is_ok() && + writeln!(file, "{}", path_line).is_ok() { + println!("[{}] ✓ Added to ~/.bashrc PATH", APP_NAME); + println!(" RESTART your terminal/IDE or run: source ~/.bashrc"); + return true; + } + } + } } } + false } -impl std::fmt::Display for ServeError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ServeError::NotFound => write!(f, "Not Found"), - ServeError::Forbidden => write!(f, "Forbidden"), - ServeError::Io(e) => write!(f, "IO Error: {}", e), +fn parse_args() -> Config { + let args: Vec = env::args().collect(); + let mut config = Config::default(); + + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "install" => { + // Handle explicit install command in main() + return config; + } + "-d" | "--dir" => { + if i + 1 < args.len() { + config.dir = PathBuf::from(&args[i + 1]); + i += 2; + } else { + eprintln!("Error: --dir requires a value"); + std::process::exit(1); + } + } + "-p" | "--port" => { + if i + 1 < args.len() { + match args[i + 1].parse() { + Ok(port) => config.port = port, + Err(_) => { + eprintln!("Error: Invalid port number"); + std::process::exit(1); + } + } + i += 2; + } else { + eprintln!("Error: --port requires a value"); + std::process::exit(1); + } + } + "-b" | "--bind" => { + if i + 1 < args.len() { + match IpAddr::from_str(&args[i + 1]) { + Ok(ip) => config.bind = ip, + Err(_) => { + eprintln!("Error: Invalid IP address"); + std::process::exit(1); + } + } + i += 2; + } else { + eprintln!("Error: --bind requires a value"); + std::process::exit(1); + } + } + "-h" | "--help" => { + println!("Usage: {} [COMMAND] [OPTIONS]", args[0]); + println!("\nCommands:"); + println!(" install Install to user directory and setup PATH"); + println!("\nOptions:"); + println!(" -d, --dir Directory to serve [default: .]"); + println!(" -p, --port Port to listen on [default: 8080]"); + println!(" -b, --bind IP address to bind to [default: 127.0.0.1]"); + println!(" -h, --help Print this help message"); + std::process::exit(0); + } + arg => { + eprintln!("Error: Unknown argument '{}'", arg); + eprintln!("Use --help for usage information"); + std::process::exit(1); + } } } -} -impl std::error::Error for ServeError {} -// --- Main Entry Point --- -fn main() -> Result<()> { - let current_exe = env::current_exe().context("Failed to get current exe path")?; + config +} + +#[tokio::main] +async fn main() -> Result<()> { + let current_exe = env::current_exe()?; let (install_dir, install_path, version_path) = get_install_paths()?; - let mut explicit_install = false; + // Check for explicit install command let args: Vec = env::args().collect(); if args.len() > 1 && args[1] == "install" { - explicit_install = true; - } - - if explicit_install { - env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); - info!("Explicit install command detected."); - match install_app(¤t_exe, &install_dir, &install_path, &version_path) { - Ok(_) => print_path_instructions(&install_dir, &install_path), - Err(e) => error!("Installation failed: {}", e), + println!("[{}] Installing...", APP_NAME); + install_app(¤t_exe, &install_dir, &install_path, &version_path)?; + + if !try_add_to_path_auto(&install_dir) { + add_to_path_instructions(&install_dir); } return Ok(()); } - let needs_update = if current_exe != install_path { - println!("[{}] Current executable is not at the standard install location.", APP_NAME); + // Auto-install/update logic + let needs_install = if current_exe != install_path { + println!("[{}] First run - installing to: {}", APP_NAME, install_path.display()); true } else { match read_version(&version_path) { - Ok(Some(ver)) if ver != VERSION => { - println!("[{}] Version mismatch: {} vs {}. Will update.", APP_NAME, ver, VERSION); + Some(ver) if ver != VERSION => { + println!("[{}] Updating from {} to {}", APP_NAME, ver, VERSION); true } - Ok(None) => { - println!("[{}] No version file found. Will write version info.", APP_NAME); + None => { + println!("[{}] Writing version info", APP_NAME); true } - Err(e) => { - eprintln!("[WARNING] Could not read version file: {}. Consider running `{} install`.", e, APP_NAME); - false - } _ => false } }; - if needs_update { - println!("[{}] Updating to: {}", APP_NAME, install_path.display()); - match install_app(¤t_exe, &install_dir, &install_path, &version_path) { - Ok(_) => { - print_path_instructions(&install_dir, &install_path); - println!("\n[{}] Proceeding to run from: {}", APP_NAME, current_exe.display()); - } - Err(e) => { - eprintln!("[WARNING] Auto-update failed: {}. Will run from current location.", e); - eprintln!(" Try running `{} install` manually.", APP_NAME); + if needs_install { + if let Err(e) = install_app(¤t_exe, &install_dir, &install_path, &version_path) { + eprintln!("[WARNING] Auto-install failed: {}. Running from current location.", e); + } else { + if !try_add_to_path_auto(&install_dir) { + add_to_path_instructions(&install_dir); } } - println!("---"); + println!(); // Add spacing } - env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + let config = parse_args(); - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build()?; - - rt.block_on(async { - let cli = Cli::parse(); - if cli.command.is_some() { - warn!("Unhandled subcommand after initial checks."); - return Ok(()); - } - run_server(cli.dir, cli.port, cli.bind).await - }) -} - -// --- Server Logic --- -async fn run_server(serve_dir_arg: PathBuf, port: u16, bind_ip: IpAddr) -> Result<()> { - let root_dir = tokio::fs::canonicalize(&serve_dir_arg).await - .with_context(|| format!("Failed to resolve path: {}", serve_dir_arg.display()))?; + let root_dir = fs::canonicalize(&config.dir) + .map_err(|e| format!("Failed to resolve path '{}': {}", config.dir.display(), e))?; if !root_dir.is_dir() { - bail!("The specified path '{}' is not a directory.", root_dir.display()); + return Err(format!("Path '{}' is not a directory", root_dir.display()).into()); } - let app_state = Arc::new(root_dir.clone()); - let addr = SocketAddr::new(bind_ip, port); - info!("Serving directory: {}", root_dir.display()); - info!("Listening on http://{}", addr); - info!("Press Ctrl+C to shut down."); + let addr = SocketAddr::new(config.bind, config.port); + println!("Serving {} at http://{}", root_dir.display(), addr); + println!("Press Ctrl+C to stop"); let listener = TcpListener::bind(addr).await?; + let root_dir = Arc::new(root_dir); loop { - let (stream, remote_addr) = tokio::select! { + let (stream, _) = tokio::select! { biased; - _ = shutdown_signal() => break, - res = listener.accept() => match res { - Ok(s_ra) => s_ra, - Err(e) => { - if e.kind() == std::io::ErrorKind::InvalidInput { - info!("Listener closed, shutting down."); - break; - } - error!("Failed to accept connection: {}", e); - continue; - } - } + _ = tokio::signal::ctrl_c() => break, + result = listener.accept() => result?, }; - debug!("Accepted connection from: {}", remote_addr); - let io = TokioIo::new(stream); - let app_state_clone = Arc::clone(&app_state); - - tokio::task::spawn(async move { - let service = service_fn(move |req| handle_request(req, Arc::clone(&app_state_clone))); - if let Err(err) = http1::Builder::new().serve_connection(io, service).with_upgrades().await { + let root_dir = Arc::clone(&root_dir); + tokio::spawn(async move { + let io = TokioIo::new(stream); + let service = service_fn(move |req| handle_request(req, Arc::clone(&root_dir))); + + if let Err(err) = http1::Builder::new() + .serve_connection(io, service) + .await + { let err_str = err.to_string(); - if !err_str.contains("connection closed") && !err_str.contains("reset by peer") - && !err_str.contains("broken pipe") && !err_str.contains("unexpected end of file") { - warn!("Error serving connection from {}: {}", remote_addr, err); + if !err_str.contains("connection closed") + && !err_str.contains("broken pipe") + && !err_str.contains("reset by peer") { + eprintln!("Connection error: {}", err); } } }); } - info!("Server shut down successfully."); + + println!("Shutting down"); Ok(()) } -async fn handle_request(req: Request, root_dir: Arc) -> Result>, Infallible> { - let response = match serve_path(&root_dir, req.uri().path(), &req).await { +async fn handle_request( + req: Request, + root_dir: Arc +) -> std::result::Result, Infallible> { + let response = match serve_request(&root_dir, &req).await { Ok(resp) => resp, - Err(e) => { - warn!("Error serving path {}: {}", req.uri().path(), e); - let status = match e { - ServeError::NotFound => StatusCode::NOT_FOUND, - ServeError::Forbidden => StatusCode::FORBIDDEN, - ServeError::Io(_) => StatusCode::INTERNAL_SERVER_ERROR, - }; + Err(err) => { Response::builder() - .status(status) + .status(err.status_code()) .header(CONTENT_TYPE, "text/plain") - .body(Full::from(status.canonical_reason().unwrap_or("Error").to_string())) + .body(Body::from(err.message())) .unwrap() } }; Ok(response) } -async fn serve_path(root_dir: &Path, req_path: &str, req: &Request) -> Result>, ServeError> { +async fn serve_request( + root_dir: &Path, + req: &Request +) -> std::result::Result, ServeError> { if req.method() != Method::GET && req.method() != Method::HEAD { return Ok(Response::builder() .status(StatusCode::METHOD_NOT_ALLOWED) .header(CONTENT_TYPE, "text/plain") - .body(Full::from("Method Not Allowed")) + .body(Body::from("Method Not Allowed")) .unwrap()); } + let req_path = req.uri().path(); let decoded_path = percent_encoding::percent_decode_str(req_path) .decode_utf8() .map_err(|_| ServeError::Forbidden)?; - let mut path_to_check = root_dir.to_path_buf(); + let mut path = root_dir.to_path_buf(); for component in Path::new(decoded_path.as_ref()).components() { match component { - Component::Normal(comp) => path_to_check.push(comp), + Component::Normal(comp) => path.push(comp), Component::ParentDir => { - if path_to_check == *root_dir { return Err(ServeError::Forbidden); } - path_to_check.pop(); + if path == root_dir { + return Err(ServeError::Forbidden); + } + path.pop(); } - _ => {} + _ => {} } } - let full_path = tokio::fs::canonicalize(&path_to_check).await?; - if !full_path.starts_with(root_dir) { - warn!("Path traversal attempt: '{}' resolved to '{}'", req_path, full_path.display()); + let canonical_path = tokio::fs::canonicalize(&path).await?; + if !canonical_path.starts_with(root_dir) { return Err(ServeError::Forbidden); } - - let metadata = tokio::fs::metadata(&full_path).await?; + + let metadata = tokio::fs::metadata(&canonical_path).await?; if metadata.is_dir() { - let index_path = full_path.join("index.html"); - match tokio::fs::metadata(&index_path).await { - Ok(index_md) if index_md.is_file() => { - serve_file(&index_path, req.method() == Method::HEAD).await - } - _ => { - if !req_path.ends_with('/') { - return Ok(Response::builder() - .status(StatusCode::FOUND) - .header(LOCATION, format!("{}/", req_path)) - .body(Full::from(Bytes::new())) - .unwrap()); - } - list_directory(&full_path, root_dir, req_path).await + let index_path = canonical_path.join("index.html"); + if tokio::fs::metadata(&index_path).await.map_or(false, |m| m.is_file()) { + serve_file(&index_path, req.method() == Method::HEAD).await + } else { + if !req_path.ends_with('/') { + return Ok(Response::builder() + .status(StatusCode::MOVED_PERMANENTLY) + .header(LOCATION, format!("{}/", req_path)) + .body(Body::new(Bytes::new())) + .unwrap()); } + serve_directory(&canonical_path, root_dir, req_path).await } } else if metadata.is_file() { - serve_file(&full_path, req.method() == Method::HEAD).await + serve_file(&canonical_path, req.method() == Method::HEAD).await } else { Err(ServeError::NotFound) } } -async fn serve_file(file_path: &Path, is_head: bool) -> Result>, ServeError> { - let mime_type = mime_guess::from_path(file_path).first_or_octet_stream(); - let body = if is_head { - Full::new(Bytes::new()) +async fn serve_file( + file_path: &Path, + head_only: bool +) -> std::result::Result, ServeError> { + let mime_type = mime_guess::from_path(file_path) + .first_or_octet_stream() + .to_string(); + + let body = if head_only { + Body::new(Bytes::new()) } else { - Full::from(tokio::fs::read(file_path).await?) + Body::from(tokio::fs::read(file_path).await?) }; - + Ok(Response::builder() .status(StatusCode::OK) - .header(CONTENT_TYPE, mime_type.as_ref()) + .header(CONTENT_TYPE, mime_type) .body(body) .unwrap()) } -async fn list_directory(dir_path: &Path, root_dir: &Path, req_path: &str) -> Result>, ServeError> { - let mut body = format!( - "Index of {}

Index of {}


",
-        req_path, req_path
+async fn serve_directory(
+    dir_path: &Path,
+    root_dir: &Path,
+    req_path: &str,
+) -> std::result::Result, ServeError> {
+    let title = format!("Index of {}", req_path);
+    let mut html = format!(
+        "\
+         {}\
+         \
+         

{}


",
+        title, title
     );
 
-    if dir_path != root_dir && req_path != "/" && !req_path.is_empty() {
-        let mut parent_uri_path = PathBuf::from(req_path.trim_end_matches('/'));
-        parent_uri_path.pop();
-        let parent_link = if parent_uri_path.as_os_str().is_empty() { 
-            "/".to_string() 
-        } else { 
-            format!("{}/", parent_uri_path.to_string_lossy()) 
+    if dir_path != root_dir {
+        let parent_path = req_path.trim_end_matches('/');
+        let parent_link = if let Some(pos) = parent_path.rfind('/') {
+            if pos == 0 { "/" } else { &parent_path[..pos + 1] }
+        } else {
+            "/"
         };
-        body.push_str(&format!("..\n", 
-            utf8_percent_encode(&parent_link, PATH_SEGMENT)));
+        html.push_str(&format!(
+            "..\n",
+            utf8_percent_encode(parent_link, PATH_SEGMENT)
+        ));
     }
 
     let mut entries = Vec::new();
-    let mut dir_iter = tokio::fs::read_dir(dir_path).await?;
-    while let Some(entry) = dir_iter.next_entry().await? { 
-        entries.push(entry); 
+    let mut dir_reader = tokio::fs::read_dir(dir_path).await?;
+    while let Some(entry) = dir_reader.next_entry().await? {
+        entries.push(entry);
     }
-    
-    entries.sort_by_key(|e| (!e.path().is_dir(), e.file_name().to_ascii_lowercase()));
+
+    entries.sort_by(|a, b| {
+        let a_is_dir = a.path().is_dir();
+        let b_is_dir = b.path().is_dir();
+        match (a_is_dir, b_is_dir) {
+            (true, false) => std::cmp::Ordering::Less,
+            (false, true) => std::cmp::Ordering::Greater,
+            _ => a.file_name().to_ascii_lowercase().cmp(&b.file_name().to_ascii_lowercase()),
+        }
+    });
 
     for entry in entries {
-        let name = entry.file_name().to_string_lossy().into_owned();
-        let mut link = name.clone();
-        if entry.file_type().await?.is_dir() { link.push('/'); }
+        let name = entry.file_name().to_string_lossy().to_string();
+        let is_dir = entry.file_type().await?.is_dir();
+        let display_name = if is_dir {
+            format!("{}/", name)
+        } else {
+            name
+        };
         
-        body.push_str(&format!("{}\n", 
-            utf8_percent_encode(&link, PATH_SEGMENT), name));
+        html.push_str(&format!(
+            "{}\n",
+            utf8_percent_encode(&display_name, PATH_SEGMENT),
+            display_name
+        ));
     }
 
-    body.push_str("

"); - + html.push_str("

"); + Ok(Response::builder() .status(StatusCode::OK) .header(CONTENT_TYPE, "text/html; charset=utf-8") - .body(Full::from(body)) + .body(Body::from(html)) .unwrap()) -} - -async fn shutdown_signal() { - tokio::signal::ctrl_c().await.expect("Failed to install CTRL+C signal handler"); - info!("Received Ctrl+C, shutting down gracefully..."); } \ No newline at end of file