41 Commits

Author SHA1 Message Date
b5a8cf91f4 feat: add workflows for testing, linting, compiling 2025-07-10 19:41:21 -05:00
5af4e3b9b7 feat: add favicon.png route 2025-07-10 19:09:00 -05:00
bab7f916d3 chore: bump to 0.2.0 2025-07-10 18:58:33 -05:00
ffedc60ed1 feat: add Server header with version 2025-07-10 18:58:24 -05:00
97d4ad57b4 docs: track feature progression in README 2025-07-10 18:54:56 -05:00
b427c9d094 feat: favicon ico conversion 2025-07-10 18:48:07 -05:00
696f18af3f feat: dynamic png-based clock favicon 2025-07-10 18:36:29 -05:00
1a7e9e3414 docs: cleanup README 2025-07-10 18:22:08 -05:00
96dcbcc318 docs: add comprehensive documentation 2025-07-10 18:08:43 -05:00
1b3f6c8864 feat: enhance duration parsing and error handling, add utility functions 2025-07-10 18:08:43 -05:00
5afffcaf07 feat: add rendering module and integrate into routes 2025-07-10 17:41:22 -05:00
4694fd6632 feat: re-implement route rendering, use duration parsing, absolute timestamps 2025-07-10 17:27:20 -05:00
279dc043d4 tests: clippy warnings as warn, no deny 2025-07-10 17:26:46 -05:00
1e36db1ff7 doc: detail 'auto' tz value logic 2025-07-10 17:26:46 -05:00
52fb1b2854 refactor: rename abbr/relative modules 2025-07-10 17:10:54 -05:00
7a6b304213 chore: configure clippy to ignore dead/unused code 2025-07-10 17:00:23 -05:00
94e5fccc40 fix: simple clippy recommendations 2025-07-10 17:00:23 -05:00
3bf37b936f fix: regex greedy pattern stops early when parsing shorter unit markers 2025-07-10 16:50:04 -05:00
a6fe3995c5 tests: use direct duration value comparisons for parse_duration tests 2025-07-10 16:45:44 -05:00
b1ea1b3957 doc: request path documentation 2025-07-10 16:34:27 -05:00
00aabfc692 feat: add justfile 2025-07-10 15:26:09 -05:00
850a399fe0 fix: solve easy clippy warnings 2025-07-10 15:26:04 -05:00
4d7f58af43 feat: fix development build path patterns for templates/fonts 2025-07-10 12:43:51 -05:00
01e71d2ff5 doc: dynamic favicon concept 2025-07-10 12:37:52 -05:00
4cf4b626de chore: smarter zoom out raster method, simpler svg 2025-07-10 12:37:45 -05:00
32b55c918c chore: update all dependencies to latest 2025-07-10 12:37:13 -05:00
babae191a4 feat: calculate size of rasterized png using content area 2025-07-10 11:53:43 -05:00
430a6ca7ac chore: cargo fmt 2025-07-10 11:40:35 -05:00
a4d0898b26 chore: update phf_codegen 2025-07-10 11:13:47 -05:00
23d09a3235 chore: reformatting files, remove parse module, move split_on_extension 2025-07-10 11:10:36 -05:00
3f57389f6c feat: improve dockerfile, better stages 2025-07-10 11:01:09 -05:00
fc5602f4c8 refactor: better error flow in parse_timezone_line 2025-07-10 11:00:10 -05:00
614cb6401d chore: update dependencies where possible 2025-07-10 10:06:58 -05:00
9d248a7c23 feat: improve build script, error handling, logging 2025-07-10 10:06:44 -05:00
56777038a0 chore: edit README, feature planning 2025-07-10 10:06:04 -05:00
0cb32482fa Update rust to 1.81 2025-02-18 14:20:41 -06:00
7207a25aef Relative time parsing format with RegEx + testing (partial) 2023-07-23 05:19:48 -05:00
3a6c4172dc Add fallback route handler 2023-07-22 18:20:29 -05:00
4e0e6f1d83 Add route for index, redirect to relative of current epoch time 2023-07-22 18:15:15 -05:00
d963ce623d Use RasterizeError for invalid extension in handle_rasterize 2023-07-22 18:06:47 -05:00
f58b18e6bb Optimize imports 2023-07-22 18:06:04 -05:00
22 changed files with 2270 additions and 905 deletions

121
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,121 @@
name: CI
on:
push:
branches: [master, develop]
pull_request:
branches: [master, develop]
env:
CARGO_TERM_COLOR: always
jobs:
clippy:
name: Clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-registry-
- name: Cache cargo index
uses: actions/cache@v4
with:
path: ~/.cargo/git
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-index-
- name: Cache cargo build
uses: actions/cache@v4
with:
path: target
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-build-target-
- name: Run clippy
run: cargo clippy --all-targets --all-features -- -D warnings
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-registry-
- name: Cache cargo index
uses: actions/cache@v4
with:
path: ~/.cargo/git
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-index-
- name: Cache cargo build
uses: actions/cache@v4
with:
path: target
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-build-target-
- name: Run tests
run: cargo test --verbose
build:
name: Build (Linux Debug)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-registry-
- name: Cache cargo index
uses: actions/cache@v4
with:
path: ~/.cargo/git
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-index-
- name: Cache cargo build
uses: actions/cache@v4
with:
path: target
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-build-target-
- name: Build project
run: cargo build --verbose

22
.github/workflows/format.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: Format Check
on:
push:
branches: [master, develop]
pull_request:
branches: [master, develop]
jobs:
format:
name: Format Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- name: Check formatting
run: cargo fmt --all -- --check

1217
Cargo.lock generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +1,35 @@
[package]
name = "time-banner"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
resvg = "0.34.1"
axum = "0.6.18"
resvg = "0.45.1"
axum = "0.8.4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.68"
tokio = { version = "1.0", features = ["full"] }
serde_json = "1.0.140"
tokio = { version = "1.46", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
futures = "0.3.28"
png = "0.17.9"
futures = "0.3.31"
png = "0.17.16"
dotenvy = "0.15.7"
envy = "0.4.2"
tera = "1.19.0"
lazy_static = "1.4.0"
timeago = "0.4.1"
chrono-tz = "0.8.3"
phf = { version = "0.11.2", features = ["macros"] }
phf_codegen = "0.11.1"
chrono = "0.4.26"
regex = "1.8.4"
tera = "1.20.0"
lazy_static = "1.5.0"
timeago = "0.4.2"
chrono-tz = "0.10.3"
phf = { version = "0.12.1", features = ["macros"] }
phf_codegen = "0.12.1"
chrono = "0.4.41"
regex = "1.11.1"
ico = "0.4.0"
[build-dependencies]
chrono = "0.4.26"
regex = "1.8.4"
phf = { version = "0.11.1", default-features = false }
phf_codegen = "0.11.1"
lazy_static = "1.4.0"
chrono = "0.4.41"
regex = "1.11.1"
phf = { version = "0.12.1", default-features = false }
phf_codegen = "0.12.1"
lazy_static = "1.5.0"

View File

@@ -1,45 +1,70 @@
# Build Stage
FROM rust:1.68.0 as builder
FROM rust:1.81.0-alpine as builder
# Install build dependencies
RUN apk add --no-cache \
musl-dev \
pkgconfig \
openssl-dev
WORKDIR /usr/src
RUN USER=root cargo new --bin time-banner
WORKDIR ./time-banner
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse
COPY ./Cargo.toml ./Cargo.toml
WORKDIR /usr/src/time-banner
# Copy dependency files for better layer caching
COPY ./Cargo.toml ./Cargo.lock* ./build.rs ./
# Copy the timezone data file needed by build.rs
COPY ./src/abbr_tz ./src/abbr_tz
# Build empty app with downloaded dependencies to produce a stable image layer for next build
RUN cargo build --release
# Build web app with own code
RUN rm src/*.rs
ADD . ./
COPY ./src ./src
RUN rm ./target/release/deps/time_banner*
RUN cargo build --release
# Strip the binary to reduce size
RUN strip target/release/time-banner
FROM debian:bullseye-slim
# Runtime Stage - Alpine for smaller size and musl compatibility
FROM alpine:3.19
ARG APP=/usr/src/app
ARG APP_USER=appuser
ARG UID=1000
ARG GID=1000
RUN apt-get update \
&& apt-get install -y ca-certificates tzdata \
&& rm -rf /var/lib/apt/lists/*
# Install runtime dependencies
RUN apk add --no-cache \
ca-certificates \
tzdata
ENV TZ=Etc/UTC \
APP_USER=appuser
ENV TZ=Etc/UTC
RUN groupadd $APP_USER \
&& useradd -g $APP_USER $APP_USER \
# Create user with specific UID/GID
RUN addgroup -g $GID -S $APP_USER \
&& adduser -u $UID -D -S -G $APP_USER $APP_USER \
&& mkdir -p ${APP}
COPY --from=builder /time-banner/target/release/time-banner ${APP}/time-banner
COPY --from=builder /time-banner/src/fonts ${APP}/fonts
COPY --from=builder /time-banner/src/templates ${APP}/templates
# Copy application files
COPY --from=builder --chown=$APP_USER:$APP_USER /usr/src/time-banner/target/release/time-banner ${APP}/time-banner
COPY --from=builder --chown=$APP_USER:$APP_USER /usr/src/time-banner/src/fonts ${APP}/fonts
COPY --from=builder --chown=$APP_USER:$APP_USER /usr/src/time-banner/src/templates ${APP}/templates
RUN chown -R $APP_USER:$APP_USER ${APP}
# Set proper permissions
RUN chmod +x ${APP}/time-banner
USER $APP_USER
WORKDIR ${APP}
EXPOSE 3000
ENV PORT 3000
# Use ARG for build-time configuration, ENV for runtime
ARG PORT=3000
ENV PORT=${PORT}
EXPOSE ${PORT}
# Add health check (using wget since curl isn't in Alpine by default)
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost:${PORT}/health || exit 1
CMD ["./time-banner"]

137
README.md
View File

@@ -1,68 +1,85 @@
# time-banner
My first Rust project, intended to offer a simple way to display the current time relative, in an image format.
## Planned Features
- Dynamic light/dark mode
- Via Query Parameters for Raster or SVG
- Via CSS for SVG
- Relative or Absolute Format
- Dynamic Formats
- Caching Abilities
- Relative caching for up to 59 seconds, purged on the minute
- Absolute caching for up to 50MB, purged on an LRU basis
- Flexible & Dynamic Browser API
- Allow users to play with format in numerous ways to query API
- Examples
- `/svg/2023-06-14-3PM-CST`
- `2023-06-14-3PM-CST.svg`
- `/jpeg/2023.06.14.33` (14th of June, 2023, 2:33 PM UTC)
- `/jpeg/2023.06.14.33T-5` (14th of June, 2023, 2:33 PM UTC-5)
Dynamically generated timestamp images
## Routes
```shell
/{time}[.{ext}]
/{rel|relative}/{time}[.{ext}]
/{abs|absolute}/{time}[.{ext}]
`GET /[relative|absolute]/[value]{.ext}?tz={timezone}&format={format_string}&now={timestamp}&static={boolean}`
- [x] `{relative|absolute}` - The display format of the time.
- [x] `{value}` - The time value to work with. Relative values can be specified by prefixing with `+` or `-`.
- [ ] Relative values are relative to the value acquired from the `now` parameter (which defaults to the current time).
- Whether the value is relative or absolute has nothing to do with the display format.
- [x] `/absolute/+0` will display the current time in UTC.
- [ ] Note: The `now` parameter is returned in the `Date` response header.
- [x] `Accept` or `{.ext}` - Determines the output format. If not specified, `.svg` is assumed.
- [ ] `Accept` requires a valid MIME type.
- [x] `.ext` requires a valid extension.
- [ ] Supported values: `.png`/`image/png`,
- [ ] `X-Timezone` or `?tz={timezone}` - The timezone to display the time in. If not specified, UTC is assumed.
- [ ] `auto` will attempt to determine the timezone from the client's IP address. Depending on currently unknown factors, this may be disregarded.
- [ ] Return detected timezone in `X-Timezone` response header.
- [ ] `?format={format}` - The format of the time to display. If not specified, `%Y-%m-%d %H:%M:%S %Z` is assumed.
- [ ] Only relevant for `absolute` values.
- [ ] `X-Date-Now` or `?now={timestamp}` - The timestamp to use for relative time calculations. If not specified, the current time is used.
- [ ] `?static={boolean}` - Whether to redirect to a static version of the URL. Useful for creating specific URLs manually.
- [ ] If a value is not passed, (`?static`), `true` is assumed. Anything other than `true`, `1` or `yes` (case-insensitive) is considered `false`.
- [ ] Some header values will be translated to query parameters if provided (not `Accept`).
- [ ] e.g. `/rel/+3600.png?static&now=1752170474` will redirect to `/relative/1752174074.png`
- [x] `/favicon.ico` - Returns a dynamic favicon of an analog clock.
- [ ] Uses naive timestamp from `X-Date-Now` if provided.
- [ ] Uses timezone from `X-Timezone` if provided.
- [ ] Uses IP geolocation to determine timezone if neither is provided.
### Examples
```
/1752170474 => 2025-07-10 12:01:14 UTC
/abs/1752170474 => 2025-07-10 12:01:14 UTC
/absolute/+3600 => 2025-07-10 13:01:14 UTC
/abs/-1800 => 2025-07-10 11:01:14 UTC
/rel/1752170474 => 15 minutes ago
/rel/+3600 => 1 hour from now
/relative/-1800 => 30 minutes ago
/relative/1752170474.png?tz=America/Chicago => 2025-07-10 06:01:14 CDT
/relative/1752170474?type=relative => 2081-01-17 12:02:28 PM
```
- If relative or absolute is not specified, it will be the opposite of the time string's format.
## Ideas
### Query Parameters
- `format` - Specify the format of the time string
- `tz` - Specify the timezone of the time string. May be ignored if the time string contains a timezone/offset.
## Structure
1. Routing
- Handle different input formats at the route layer
2. Parsing
- Module for parsing input
3. Cache Layer
- Given all route options, provide a globally available cache for the next layer
4. SVG Template Rendering
- Template rendering based on parsed input
5. (Optional) Rasterization
- If rasterization is requested, render SVG to PNG
6. (Catch-all) Error Handling
- All errors/panics will be caught in separate middleware
## Input Parsing
- Date formatting will be guesswork, but can be specified with `?format=` parameter.
- To avoid abuse, it will be limited to a subset of the `chrono` formatting options.
- The assumed extension when not specified is `.svg` for performance sake.
- `.png` is also available. `.jpeg` and `.webp` are planned.
- Time is not required, but will default each value to 0 (except HOUR, which is the minimum specified value).
- Millisecond precision is allowed, but will be ignored in most outputs. Periods or commas are allowed as separators.
- Timezones can be qualified in a number of ways, but will default to UTC if not specified.
- Fully qualified TZ identifiers like "America/Chicago" are specified using the `tz` query parameter.
- Abbreviated TZ identifiers like "CST" are specified inside the time string, after the time, separated by a dash.
- Abbreviated terms are incredibly ambiguous, and should be avoided if possible. For ease of use, they are
available, but several of them are ambiguous, and the preferred TZ has been specified in code.
- Full table available in [`abbr_tz`](./src/abbr_tz). Comments designated with `#`. Preferred interpretation
designated arbitrarily by me. Table sourced
from [Wikipedia](https://en.wikipedia.org/wiki/List_of_time_zone_abbreviations)
- Frontend with React for Demo
- Refetch favicon every 10 minutes
- Click to copy image URLs
- Dynamic Examples
- Dynamic light/dark mode
- `?theme={auto|light|dark}`, default `light`
- Customizable SVG templates
- Dynamic favicon generation
- Clock svg optimized for favicon size
- Move hands to the current time
- Use geolocation of request IP to determine timezone
- Advanced: create sun/moon SVG based on local time
- Support for different timezone formats in query parameters or headers
- `?tz=...` or `X-Timezone: ...`
- `CST` or `America/Chicago` or `UTC-6` or `GMT-6` or `-0600`
- Automatically guessed based on geolocation of source IP address
- Complex caching abilities
- Multi-level caching (disk with max size, memory)
- Automatic expiry of relative items
- Use browser cache headers
- Detect force refreshs and allow cache busting
- `Accept` header support
- IP-based rate limiting
- Multi-domain rate limiting factors
- Computational cost: 1ms = 1 token, max 100 tokens per minute
- Base rate: 3 requests per second
- Cached Conversions
- If PNG is cached, then JPEG/WEBP/etc. can be converted from cached PNG
- Additional date input formats
- 2025-07-10-12:01:14
- 2025-07-10-12:01
- 2025-07-10-12:01:14-06:00
- 2025-07-10-12:01:14-06
- 2025-07-10-12:01:14-06:00:00
- 2025-07-10-12:01:14-06:00:00.000
- 2025-07-10-12:01:14-06:00:00Z-06:00

229
build.rs
View File

@@ -1,71 +1,194 @@
use lazy_static::lazy_static;
use regex::Regex;
use std::env;
use std::fmt;
use std::fs::File;
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::Path;
use regex::Regex;
use lazy_static::lazy_static;
lazy_static! {
static ref FULL_PATTERN: Regex = Regex::new(r"([A-Z]+)\s\t.+\s\tUTC([+±]\d{2}(?::\d{2})?)").unwrap();
static ref OFFSET_PATTERN: Regex = Regex::new(r"([+±])(\d{2}(?::\d{2})?)").unwrap();
/// Error types for build script failures
#[derive(Debug)]
enum BuildError {
Io(std::io::Error),
Regex(String),
Parse(String),
Env(env::VarError),
}
const HOUR: u32 = 3600;
impl fmt::Display for BuildError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
BuildError::Io(e) => write!(f, "IO error: {}", e),
BuildError::Regex(msg) => write!(f, "Regex error: {}", msg),
BuildError::Parse(msg) => write!(f, "Parse error: {}", msg),
BuildError::Env(e) => write!(f, "Environment error: {}", e),
}
}
}
fn parse_offset(raw_offset: &str) -> i32 {
let capture = OFFSET_PATTERN.captures(raw_offset).expect("RegEx failed to match offset");
println!("{}: {}", raw_offset, capture.get(1).expect("First group capture failed").as_str());
impl From<std::io::Error> for BuildError {
fn from(error: std::io::Error) -> Self {
BuildError::Io(error)
}
}
let is_west = capture.get(1).unwrap().as_str() == "";
let time = capture.get(2).expect("Second group capture failed").as_str();
let (hours, minutes) = if time.contains(':') {
let mut split = time.split(':');
let hours = split.next().unwrap().parse::<u32>().unwrap();
let minutes = split.next().unwrap().parse::<u32>().unwrap();
impl From<env::VarError> for BuildError {
fn from(error: env::VarError) -> Self {
BuildError::Env(error)
}
}
(hours, minutes)
} else {
// Minutes not specified, assume 0
(time.parse::<u32>().unwrap(), 0)
lazy_static! {
/// Regex to match timezone lines: "ABBR \t Description \t UTC±HH:MM"
static ref TIMEZONE_PATTERN: Regex =
Regex::new(r"([A-Z]+)\s\t.+\s\tUTC([+±]\d{2}(?::\d{2})?)").unwrap();
/// Regex to parse UTC offset format: "±HH:MM" or "±HH"
static ref OFFSET_PATTERN: Regex =
Regex::new(r"([+±])(\d{2})(?::(\d{2}))?").unwrap();
}
const SECONDS_PER_HOUR: i32 = 3600;
const SECONDS_PER_MINUTE: i32 = 60;
/// Parse a UTC offset string (e.g., "+05:30", "-08", "±00") into seconds from UTC
fn parse_utc_offset(raw_offset: &str) -> Result<i32, BuildError> {
let captures = OFFSET_PATTERN.captures(raw_offset).ok_or_else(|| {
BuildError::Regex(format!("Failed to match offset pattern: {}", raw_offset))
})?;
// Handle ± (variable offset) as UTC
let sign = captures.get(1).unwrap().as_str();
if sign == "±" {
return Ok(0);
}
let hours_str = captures.get(2).unwrap().as_str();
let minutes_str = captures.get(3).map(|m| m.as_str()).unwrap_or("0");
let hours: i32 = hours_str
.parse()
.map_err(|e| BuildError::Parse(format!("Invalid hours '{}': {}", hours_str, e)))?;
let minutes: i32 = minutes_str
.parse()
.map_err(|e| BuildError::Parse(format!("Invalid minutes '{}': {}", minutes_str, e)))?;
// Validate ranges
if hours > 23 {
return Err(BuildError::Parse(format!("Hours out of range: {}", hours)));
}
if minutes > 59 {
return Err(BuildError::Parse(format!(
"Minutes out of range: {}",
minutes
)));
}
let total_seconds = (hours * SECONDS_PER_HOUR) + (minutes * SECONDS_PER_MINUTE);
// Apply sign ( is west/negative, + is east/positive)
Ok(match sign {
"" => -total_seconds,
"+" => total_seconds,
_ => unreachable!("Regex should only match +, , or ±"),
})
}
/// Parse a single timezone line and extract abbreviation and offset
fn parse_timezone_line(line: &str) -> Result<Option<(String, i32)>, BuildError> {
// Skip comment lines
if line.trim().starts_with('#') || line.trim().is_empty() {
return Ok(None);
}
let captures = TIMEZONE_PATTERN
.captures(line)
.ok_or_else(|| BuildError::Regex(format!("Failed to match timezone pattern: {}", line)))?;
let abbreviation = match captures.get(1) {
Some(m) => m.as_str().to_string(),
None => {
return Err(BuildError::Regex(format!(
"Failed to extract abbreviation from line: {}",
line
)))
}
};
let raw_offset = match captures.get(2) {
Some(m) => m.as_str(),
None => {
return Err(BuildError::Regex(format!(
"Failed to extract offset from line: {}",
line
)))
}
};
let value = (hours * HOUR) + (minutes * 60);
return if is_west { value as i32 * -1 } else { value as i32 };
let offset = parse_utc_offset(raw_offset)?;
Ok(Some((abbreviation, offset)))
}
/// Generate the PHF map code for timezone abbreviations to UTC offsets
fn generate_timezone_map() -> Result<(), BuildError> {
let out_dir = env::var("OUT_DIR")?;
let output_path = Path::new(&out_dir).join("timezone_map.rs");
let tz_path = Path::new("./src/abbr_tz");
let tz_file = File::open(tz_path)?;
let reader = BufReader::new(tz_file);
let mut out_file = BufWriter::new(File::create(&output_path)?);
let mut builder = phf_codegen::Map::<String>::new();
let mut processed_count = 0;
let mut skipped_count = 0;
for line in reader.lines() {
let line = line?;
match parse_timezone_line(&line)? {
Some((abbreviation, offset)) => {
builder.entry(abbreviation.clone(), offset.to_string());
processed_count += 1;
}
None => {
skipped_count += 1;
}
}
}
// Generate the PHF map
writeln!(
&mut out_file,
"/// Auto-generated timezone abbreviation to UTC offset (in seconds) mapping"
)?;
writeln!(
&mut out_file,
"/// Generated from {} timezone definitions ({} processed, {} skipped)",
processed_count + skipped_count,
processed_count,
skipped_count
)?;
writeln!(
&mut out_file,
"pub static TIMEZONE_OFFSETS: phf::Map<&'static str, i32> = {};",
builder.build()
)?;
println!(
"cargo:warning=Generated timezone map with {} entries",
processed_count
);
Ok(())
}
fn main() {
let path = Path::new(&env::var("OUT_DIR").unwrap()).join("codegen.rs");
let raw_tz = BufReader::new(File::open("./src/abbr_tz").unwrap());
let mut file = BufWriter::new(File::create(&path).unwrap());
let mut builder: phf_codegen::Map<String> = phf_codegen::Map::new();
for line in raw_tz.lines() {
let line = line.unwrap();
if line.starts_with('#') {
continue;
if let Err(e) = generate_timezone_map() {
panic!("Build script failed: {}", e);
}
let capture = FULL_PATTERN.captures(&line).expect("RegEx failed to match line");
let abbreviation = capture.get(1).unwrap().as_str();
let raw_offset = capture.get(2).unwrap().as_str();
let offset = if !raw_offset.starts_with('±') {
parse_offset(raw_offset)
} else {
0
};
builder.entry(String::from(abbreviation), &format!("\"{}\"", offset).to_string());
}
write!(
&mut file,
"static TIMEZONES: phf::Map<&'static str, &'static str> = {}",
builder.build()
)
.unwrap();
write!(&mut file, ";\n").unwrap();
// Tell Cargo to re-run this build script if the timezone file changes
println!("cargo:rerun-if-changed=src/abbr_tz");
}

115
justfile Normal file
View File

@@ -0,0 +1,115 @@
# Variables
image_name := "time-banner"
container_name := "time-banner-dev"
port := "3000"
# Default recipe
default:
@just --list
# Development server with hot reload
dev:
@echo "🚀 Starting development server..."
cargo watch -x "run --bin time-banner"
# Simple development server (no hot reload)
run:
@echo "🚀 Starting server..."
cargo run --bin time-banner
# Comprehensive check pipeline
check: format lint build test docker-build
@echo "✅ All checks passed!"
# Format code
format:
@echo "🎨 Formatting code..."
cargo fmt --all
# Check formatting
format-check:
@echo "🔍 Checking formatting..."
cargo fmt --all -- --check
# Lint with clippy
lint:
@echo "🔍 Running clippy..."
cargo clippy --all-targets --all-features --
# Build project
build:
@echo "🔨 Building project..."
cargo build --release
# Run tests
test:
@echo "🧪 Running tests..."
cargo test
# Build Docker image
docker-build:
@echo "🐳 Building Docker image..."
docker build -t {{image_name}}:latest .
# Run Docker container
docker-run: docker-build
@echo "🚀 Running Docker container..."
docker run --rm -d --name {{container_name}} -p {{port}}:{{port}} {{image_name}}:latest
@echo "Container started at http://localhost:{{port}}"
# Stop Docker container
docker-stop:
@echo "🛑 Stopping Docker container..."
docker stop {{container_name}} || true
# Docker logs
docker-logs:
@echo "📋 Showing Docker logs..."
docker logs {{container_name}}
# Follow Docker logs
docker-logs-follow:
@echo "📋 Following Docker logs..."
docker logs -f {{container_name}}
# Clean Docker artifacts
docker-clean: docker-stop
@echo "🧹 Cleaning Docker artifacts..."
docker rmi {{image_name}}:latest || true
# Full Docker development cycle
docker-dev: docker-clean docker-run
@echo "🐳 Docker development environment ready!"
# Clean cargo artifacts
clean:
@echo "🧹 Cleaning cargo artifacts..."
cargo clean
# Install development dependencies
install-deps:
@echo "📦 Installing development dependencies..."
cargo install cargo-watch
# Security audit
audit:
@echo "🔒 Running security audit..."
cargo audit
# Check dependencies for updates
outdated:
@echo "📅 Checking for outdated dependencies..."
cargo outdated
# Release build with optimizations
release:
@echo "🚀 Building release version..."
cargo build --release
# Full CI pipeline (like what would run in CI)
ci: format-check lint build test docker-build
@echo "🎯 CI pipeline completed!"
# Quick development check (faster than full check)
quick: format lint
@echo "⚡ Quick check completed!"

View File

@@ -1,31 +0,0 @@
use chrono::FixedOffset;
// Generated by build.rs, phf_codegen
include!(concat!(env!("OUT_DIR"), "/codegen.rs"));
/*
Parse an abbreviation of a timezone into a UTC offset.
Note: This is not standardized at all and is simply built on a reference of Time Zone abbreviations
from Wikipedia (as of 2023-7-20).
*/
pub fn parse_abbreviation(abbreviation: &str) -> Result<FixedOffset, String> {
let offset_integer_string = TIMEZONES.get(abbreviation);
if offset_integer_string.is_none() {
return Err("Failed to find abbreviation".to_string());
}
let offset = FixedOffset::east_opt(offset_integer_string.unwrap().parse().expect("Failed to parse stored offset"));
return offset.ok_or("Failed to parse offset".to_string());
}
#[cfg(test)]
mod tests {
use chrono::FixedOffset;
use crate::abbr::parse_abbreviation;
#[test]
fn parse_offset() {
assert_eq!(parse_abbreviation("CST").unwrap(), FixedOffset::west_opt(6 * 3600).unwrap());
}
}

92
src/abbr_tz.rs Normal file
View File

@@ -0,0 +1,92 @@
use chrono::FixedOffset;
// Generated by build.rs - timezone abbreviation to UTC offset mapping
include!(concat!(env!("OUT_DIR"), "/timezone_map.rs"));
/// Parse a timezone abbreviation into a UTC offset.
///
/// This uses a pre-generated map of timezone abbreviations to their UTC offsets
/// in seconds. The mapping is based on the Wikipedia reference of timezone
/// abbreviations (as of 2023-07-20).
///
/// Note: Timezone abbreviations are not standardized and can be ambiguous.
/// This implementation uses preferred interpretations for conflicting abbreviations.
///
/// # Arguments
/// * `abbreviation` - The timezone abbreviation (e.g., "CST", "EST", "PST")
///
/// # Returns
/// * `Ok(FixedOffset)` - The UTC offset for the timezone
/// * `Err(String)` - Error message if abbreviation is not found or invalid
///
/// # Examples
/// ```
/// use chrono::FixedOffset;
///
/// let cst = parse_abbreviation("CST").unwrap();
/// assert_eq!(cst, FixedOffset::west_opt(6 * 3600).unwrap());
/// ```
pub fn parse_abbreviation(abbreviation: &str) -> Result<FixedOffset, String> {
let offset_seconds = TIMEZONE_OFFSETS
.get(abbreviation)
.ok_or_else(|| format!("Unknown timezone abbreviation: {}", abbreviation))?;
// Convert seconds to FixedOffset
// Positive offsets are east of UTC, negative are west
let offset = if *offset_seconds >= 0 {
FixedOffset::east_opt(*offset_seconds)
} else {
FixedOffset::west_opt(-*offset_seconds)
};
offset.ok_or_else(|| {
format!(
"Invalid offset for timezone {}: {} seconds",
abbreviation, offset_seconds
)
})
}
#[cfg(test)]
mod tests {
use crate::abbr_tz::parse_abbreviation;
use chrono::FixedOffset;
#[test]
fn test_parse_cst() {
// CST (Central Standard Time) is UTC-6
let cst = parse_abbreviation("CST").unwrap();
assert_eq!(cst, FixedOffset::west_opt(6 * 3600).unwrap());
}
#[test]
fn test_parse_est() {
// EST (Eastern Standard Time) is UTC-5
let est = parse_abbreviation("EST").unwrap();
assert_eq!(est, FixedOffset::west_opt(5 * 3600).unwrap());
}
#[test]
fn test_parse_utc() {
// UTC should be zero offset
let utc = parse_abbreviation("UTC").unwrap();
assert_eq!(utc, FixedOffset::east_opt(0).unwrap());
}
#[test]
fn test_parse_unknown() {
// Unknown abbreviation should return error
let result = parse_abbreviation("INVALID");
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("Unknown timezone abbreviation"));
}
#[test]
fn test_parse_positive_offset() {
// JST (Japan Standard Time) is UTC+9
let jst = parse_abbreviation("JST").unwrap();
assert_eq!(jst, FixedOffset::east_opt(9 * 3600).unwrap());
}
}

View File

@@ -1,6 +1,7 @@
use serde::Deserialize;
use tracing::Level;
/// Application environment configuration.
#[derive(Deserialize, Debug)]
#[serde(rename_all = "lowercase")]
pub enum Environment {
@@ -8,6 +9,11 @@ pub enum Environment {
Development,
}
/// Main configuration struct parsed from environment variables.
///
/// Environment variables:
/// - `ENV`: "production" or "development" (default: development)
/// - `PORT`: TCP port number (default: 3000)
#[derive(Deserialize, Debug)]
pub struct Configuration {
#[serde(default = "default_env")]
@@ -26,6 +32,10 @@ fn default_env() -> Environment {
}
impl Configuration {
/// Returns the socket address to bind to based on environment.
///
/// - Production: 0.0.0.0 (all interfaces)
/// - Development: 127.0.0.1 (localhost only)
pub fn socket_addr(&self) -> [u8; 4] {
match self.env {
Environment::Production => {
@@ -49,6 +59,10 @@ impl Configuration {
}
}
/// Returns the appropriate log level for the environment.
///
/// - Production: INFO
/// - Development: DEBUG
pub fn log_level(&self) -> Level {
match self.env {
Environment::Production => Level::INFO,

326
src/duration.rs Normal file
View File

@@ -0,0 +1,326 @@
//! Human-readable duration parsing with support for mixed time units.
//!
//! Parses strings like "1y2mon3w4d5h6m7s", "+1year", or "-3h30m" into chrono Duration objects.
//! Time units can appear in any order and use various abbreviations.
use chrono::{DateTime, Duration, Utc};
use lazy_static::lazy_static;
use regex::Regex;
use crate::error::TimeBannerError;
/// Extends chrono::Duration with month support using approximate calendar math.
pub trait Months {
fn months(count: i32) -> Self;
}
impl Months for Duration {
/// Creates a duration representing the given number of months.
/// Uses 365.25/12 ≈ 30.44 days per month for approximation.
fn months(count: i32) -> Self {
Duration::milliseconds(
(Duration::days(1).num_milliseconds() as f64 * (365.25f64 / 12f64)) as i64,
) * count
}
}
lazy_static! {
/// Regex pattern matching duration strings with flexible ordering and abbreviations.
///
/// Supports:
/// - Optional +/- sign
/// - Years: y, yr, yrs, year, years
/// - Months: mon, month, months
/// - Weeks: w, wk, wks, week, weeks
/// - Days: d, day, days
/// - Hours: h, hr, hrs, hour, hours
/// - Minutes: m, min, mins, minute, minutes
/// - Seconds: s, sec, secs, second, seconds
///
/// Time units must appear in descending order of magnitude, e.g. "1y2d" is valid, "1d2y" is not.
static ref FULL_RELATIVE_PATTERN: Regex = Regex::new(concat!(
"(?<sign>[-+])?",
r"(?:(?<year>\d+)\s?(?:years?|yrs?|y)\s*)?",
r"(?:(?<month>\d+)\s?(?:months?|mon)\s*)?",
r"(?:(?<week>\d+)\s?(?:weeks?|wks?|w)\s*)?",
r"(?:(?<day>\d+)\s?(?:days?|d)\s*)?",
r"(?:(?<hour>\d+)\s?(?:hours?|hrs?|h)\s*)?",
r"(?:(?<minute>\d+)\s?(?:minutes?|mins?|m)\s*)?",
r"(?:(?<second>\d+)\s?(?:seconds?|secs?|s)\s*)?"
))
.unwrap();
}
/// Parses a human-readable duration string into a chrono Duration.
///
/// Examples:
/// - `"1y2d"` → 1 year + 2 days
/// - `"+3h30m"` → +3.5 hours
/// - `"-1week"` → -7 days
/// - `"2months4days"` → ~2.03 months
///
/// Years include leap year compensation (+6 hours per year).
/// Empty strings return zero duration.
pub fn parse_duration(str: &str) -> Result<Duration, String> {
let capture = FULL_RELATIVE_PATTERN.captures(str).unwrap();
let mut value = Duration::zero();
if let Some(raw_year) = capture.name("year") {
value += match raw_year.as_str().parse::<i64>() {
Ok(year) => {
Duration::days(year * 365)
+ (if year > 0 {
Duration::hours(6) * year as i32 // Leap year compensation
} else {
Duration::zero()
})
}
Err(e) => {
return Err(format!(
"Could not parse year from {} ({})",
raw_year.as_str(),
e
))
}
};
}
if let Some(raw_month) = capture.name("month") {
value += match raw_month.as_str().parse::<i32>() {
Ok(month) => Duration::months(month),
Err(e) => {
return Err(format!(
"Could not parse month from {} ({})",
raw_month.as_str(),
e
))
}
};
}
if let Some(raw_week) = capture.name("week") {
value += match raw_week.as_str().parse::<i64>() {
Ok(week) => Duration::days(7) * week as i32,
Err(e) => {
return Err(format!(
"Could not parse week from {} ({})",
raw_week.as_str(),
e
))
}
};
}
if let Some(raw_day) = capture.name("day") {
value += match raw_day.as_str().parse::<i64>() {
Ok(day) => Duration::days(day),
Err(e) => {
return Err(format!(
"Could not parse day from {} ({})",
raw_day.as_str(),
e
))
}
};
}
if let Some(raw_hour) = capture.name("hour") {
value += match raw_hour.as_str().parse::<i64>() {
Ok(hour) => Duration::hours(hour),
Err(e) => {
return Err(format!(
"Could not parse hour from {} ({})",
raw_hour.as_str(),
e
))
}
};
}
if let Some(raw_minute) = capture.name("minute") {
value += match raw_minute.as_str().parse::<i64>() {
Ok(minute) => Duration::minutes(minute),
Err(e) => {
return Err(format!(
"Could not parse minute from {} ({})",
raw_minute.as_str(),
e
))
}
};
}
if let Some(raw_second) = capture.name("second") {
value += match raw_second.as_str().parse::<i64>() {
Ok(second) => Duration::seconds(second),
Err(e) => {
return Err(format!(
"Could not parse second from {} ({})",
raw_second.as_str(),
e
))
}
};
}
if let Some(raw_sign) = capture.name("sign") {
match raw_sign.as_str() {
"-" => value = -value,
"+" => (),
_ => return Err(format!("Could not parse sign from {}", raw_sign.as_str())),
};
}
Ok(value)
}
/// Converts Unix epoch timestamp to UTC DateTime.
pub fn parse_epoch_into_datetime(epoch: i64) -> Option<DateTime<Utc>> {
DateTime::from_timestamp(epoch, 0)
}
/// Parses various time value formats into a UTC datetime.
///
/// Supports:
/// - Relative offsets: "+3600", "-1800" (seconds from now)
/// - Duration strings: "+1y2d", "-3h30m" (using duration parser)
/// - Epoch timestamps: "1752170474" (Unix timestamp)
pub fn parse_time_value(raw_time: &str) -> Result<DateTime<Utc>, TimeBannerError> {
// Handle relative time values (starting with + or -, or duration strings like "1y2d")
if raw_time.starts_with('+') || raw_time.starts_with('-') {
let now = Utc::now();
// Try parsing as simple offset seconds first
if let Ok(offset_seconds) = raw_time.parse::<i64>() {
return Ok(now + Duration::seconds(offset_seconds));
}
// Try parsing as duration string (e.g., "+1y2d", "-3h30m")
if let Ok(duration) = parse_duration(raw_time) {
return Ok(now + duration);
}
return Err(TimeBannerError::ParseError(format!(
"Could not parse relative time: {}",
raw_time
)));
}
// Try to parse as epoch timestamp
if let Ok(epoch) = raw_time.parse::<i64>() {
return parse_epoch_into_datetime(epoch)
.ok_or_else(|| TimeBannerError::ParseError("Invalid timestamp".to_string()));
}
Err(TimeBannerError::ParseError(format!(
"Could not parse time value: {}",
raw_time
)))
}
#[cfg(test)]
mod tests {
use crate::duration::{parse_duration, Months};
use chrono::Duration;
#[test]
fn parse_empty() {
assert_eq!(parse_duration(""), Ok(Duration::zero()));
assert_eq!(parse_duration(" "), Ok(Duration::zero()));
assert_eq!(parse_duration(" "), Ok(Duration::zero()));
}
#[test]
fn parse_composite() {
assert_eq!(
parse_duration("1y2mon3w4d5h6m7s"),
Ok(Duration::days(365)
+ Duration::hours(6) // leap year compensation
+ Duration::months(2)
+ Duration::weeks(3)
+ Duration::days(4)
+ Duration::hours(5)
+ Duration::minutes(6)
+ Duration::seconds(7)),
"1y2mon3w4d5h6m7s"
);
assert_eq!(
parse_duration("19year33weeks4d9min"),
Ok(Duration::days(365 * 19)
+ Duration::hours(6 * 19)
+ Duration::days(33 * 7 + 4)
+ Duration::minutes(9)),
"19year33weeks4d9min"
);
}
#[test]
fn parse_year() {
assert_eq!(
parse_duration("1y"),
Ok(Duration::days(365) + Duration::hours(6))
);
assert_eq!(
parse_duration("2year"),
Ok(Duration::days(365 * 2) + Duration::hours(6 * 2))
);
assert_eq!(
parse_duration("144years"),
Ok(Duration::days(365 * 144) + Duration::hours(6 * 144))
);
}
#[test]
fn parse_month() {
assert_eq!(Duration::zero(), parse_duration("0mon").unwrap());
assert_eq!(Duration::months(3), parse_duration("3mon").unwrap());
assert_eq!(Duration::months(-14), parse_duration("-14mon").unwrap());
assert_eq!(Duration::months(144), parse_duration("+144months").unwrap());
}
#[test]
fn parse_week() {
assert_eq!(Duration::zero(), parse_duration("0w").unwrap());
assert_eq!(Duration::weeks(7), parse_duration("7w").unwrap());
assert_eq!(Duration::weeks(19), parse_duration("19week").unwrap());
assert_eq!(Duration::weeks(433), parse_duration("433weeks").unwrap());
}
#[test]
fn parse_day() {
assert_eq!(Duration::zero(), parse_duration("0d").unwrap());
assert_eq!(Duration::days(9), parse_duration("9d").unwrap());
assert_eq!(Duration::days(43), parse_duration("43day").unwrap());
assert_eq!(Duration::days(969), parse_duration("969days").unwrap());
}
#[test]
fn parse_hour() {
assert_eq!(Duration::zero(), parse_duration("0h").unwrap());
assert_eq!(Duration::hours(4), parse_duration("4h").unwrap());
assert_eq!(Duration::hours(150), parse_duration("150hour").unwrap());
assert_eq!(Duration::hours(777), parse_duration("777hours").unwrap());
}
#[test]
fn parse_minute() {
assert_eq!(Duration::zero(), parse_duration("0m").unwrap());
assert_eq!(Duration::minutes(5), parse_duration("5m").unwrap());
assert_eq!(Duration::minutes(60), parse_duration("60min").unwrap());
assert_eq!(
Duration::minutes(999),
parse_duration("999minutes").unwrap()
);
}
#[test]
fn parse_second() {
assert_eq!(Duration::zero(), parse_duration("0s").unwrap());
assert_eq!(Duration::seconds(6), parse_duration("6s").unwrap());
assert_eq!(Duration::minutes(1), parse_duration("60sec").unwrap());
assert_eq!(
Duration::seconds(999),
parse_duration("999seconds").unwrap()
);
}
}

View File

@@ -1,27 +1,61 @@
use axum::body::Full;
use axum::http::{StatusCode};
use axum::Json;
use axum::response::{IntoResponse, Response};
use serde::{Serialize, Deserialize};
use axum::{http::StatusCode, response::Json};
use serde::Serialize;
/// Application-specific errors that can occur during request processing.
#[derive(Debug)]
pub enum TimeBannerError {
/// Input parsing errors (invalid time formats, bad parameters, etc.)
ParseError(String),
/// Template rendering failures
RenderError(String),
/// SVG to PNG conversion failures
RasterizeError(String),
/// 404 Not Found
NotFound,
}
#[derive(Serialize, Deserialize)]
/// JSON error response format for HTTP clients.
#[derive(Serialize)]
pub struct ErrorResponse {
code: u16,
error: String,
message: String,
}
/// Converts application errors into standardized HTTP responses with JSON bodies.
///
/// Returns appropriate status codes:
/// - 400 Bad Request: ParseError
/// - 500 Internal Server Error: RenderError, RasterizeError
/// - 404 Not Found: NotFound
pub fn get_error_response(error: TimeBannerError) -> (StatusCode, Json<ErrorResponse>) {
let (code, message) = match error {
TimeBannerError::RenderError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, format!("RenderError :: {}", msg)),
TimeBannerError::ParseError(msg) => (StatusCode::BAD_REQUEST, format!("ParserError :: {}", msg)),
TimeBannerError::RasterizeError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, format!("RasertizeError :: {}", msg))
};
(code, Json(ErrorResponse { code: code.as_u16(), message }))
match error {
TimeBannerError::ParseError(msg) => (
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "ParseError".to_string(),
message: msg,
}),
),
TimeBannerError::RenderError(msg) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "RenderError".to_string(),
message: msg,
}),
),
TimeBannerError::RasterizeError(msg) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "RasterizeError".to_string(),
message: msg,
}),
),
TimeBannerError::NotFound => (
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "NotFound".to_string(),
message: "The requested resource was not found".to_string(),
}),
),
}
}

View File

@@ -1,43 +1,67 @@
use std::net::SocketAddr;
use axum::{Router, routing::get};
use dotenvy::dotenv;
use crate::routes::{
absolute_handler, fallback_handler, favicon_handler, favicon_png_handler, implicit_handler,
index_handler, relative_handler,
};
use axum::{http::HeaderValue, response::Response, routing::get, Router};
use config::Configuration;
use crate::routes::{relative_handler, implicit_handler, absolute_handler};
use dotenvy::dotenv;
mod abbr_tz;
mod config;
mod raster;
mod abbr;
mod routes;
mod parse;
mod template;
mod duration;
mod error;
mod raster;
mod render;
mod routes;
mod template;
mod utils;
#[tokio::main]
async fn main() {
// Parse dotenv files and expose them as environment variables
// Development-only: Parse dotenv files and expose them as environment variables
#[cfg(debug_assertions)]
dotenv().ok();
// envy uses our Configuration struct to parse environment variables
let config = envy::from_env::<Configuration>().expect("Please provide PORT env var");
// Envy uses our Configuration struct to parse environment variables
let config = envy::from_env::<Configuration>().expect("Failed to parse environment variables");
// initialize tracing
// Initialize tracing
tracing_subscriber::fmt()
// With the log_level from our config
.with_max_level(config.log_level())
.init();
let app = Router::new()
.route("/:path", get(implicit_handler))
.route("/rel/:path", get(relative_handler))
.route("/relative/:path", get(relative_handler))
.route("/absolute/:path", get(absolute_handler))
.route("/abs/:path", get(absolute_handler));
.route("/", get(index_handler))
.route("/favicon.ico", get(favicon_handler))
.route("/favicon.png", get(favicon_png_handler))
.route("/{path}", get(implicit_handler))
.route("/rel/{path}", get(relative_handler))
.route("/relative/{path}", get(relative_handler))
.route("/absolute/{path}", get(absolute_handler))
.route("/abs/{path}", get(absolute_handler))
.fallback(fallback_handler)
.layer(axum::middleware::map_response(add_server_header));
let addr = SocketAddr::from((config.socket_addr(), config.port));
axum::Server::bind(&addr)
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
axum::serve(
tokio::net::TcpListener::bind(addr).await.unwrap(),
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.unwrap();
}
/// Middleware to add server header with application version
async fn add_server_header(mut response: Response) -> Response {
let version = env!("CARGO_PKG_VERSION");
let server_header = format!("time-banner/{}", version);
if let Ok(header_value) = HeaderValue::from_str(&server_header) {
response.headers_mut().insert("Server", header_value);
}
response
}

View File

@@ -1,26 +0,0 @@
use chrono::{DateTime, FixedOffset, Utc};
/// Split a path into a tuple of the preceding path and the extension.
/// Can handle paths with multiple dots (period characters).
/// Returns None if there is no extension.
/// Returns None if the preceding path is empty (for example, dotfiles like ".env").
pub fn split_on_extension(path: &str) -> Option<(&str, &str)> {
let split = path.rsplit_once('.');
if split.is_none() { return None; }
// Check that the file is not a dotfile (.env)
if split.unwrap().0.len() == 0 {
return None;
}
Some(split.unwrap())
}
pub fn parse_absolute(raw: String) -> Result<(DateTime<Utc>, FixedOffset), String> {
let datetime_with_offset = DateTime::parse_from_rfc3339(&raw);
if datetime_with_offset.is_err() {
return Err("Failed to parse datetime".to_string());
}
Ok((datetime_with_offset.unwrap().with_timezone(&Utc), *(datetime_with_offset.unwrap().offset())))
}

View File

@@ -1,6 +1,7 @@
use resvg::usvg::fontdb;
use resvg::{tiny_skia, usvg};
use resvg::usvg::{fontdb, TreeParsing, TreeTextToPath};
/// Errors that can occur during SVG rasterization.
#[derive(Debug, Clone)]
pub struct RenderError {
pub message: Option<String>,
@@ -21,34 +22,53 @@ pub struct Rasterizer {
}
impl Rasterizer {
/// Creates a new rasterizer and loads available fonts.
pub fn new() -> Self {
let mut fontdb = fontdb::Database::new();
fontdb.load_system_fonts();
fontdb.load_fonts_dir("./fonts");
fontdb.load_fonts_dir(if cfg!(debug_assertions) {
"src/fonts"
} else {
"fonts"
});
Self {
font_db: fontdb
}
Self { font_db: fontdb }
}
/// Converts SVG data to PNG.
pub fn render(&self, svg_data: Vec<u8>) -> Result<Vec<u8>, RenderError> {
let tree = {
let opt = usvg::Options::default();
let mut tree_result = usvg::Tree::from_data(&*svg_data, &opt);
if tree_result.is_err() { return Err(RenderError { message: Some("Failed to parse".to_string()) }); }
let opt = usvg::Options {
fontdb: std::sync::Arc::new(self.font_db.clone()),
..Default::default()
};
let tree_result = usvg::Tree::from_data(&svg_data, &opt);
if tree_result.is_err() {
return Err(RenderError {
message: Some("Failed to parse".to_string()),
});
}
let tree = tree_result.as_mut().unwrap();
tree.convert_text(&self.font_db);
resvg::Tree::from_usvg(&tree)
tree_result.unwrap()
};
let pixmap_size = tree.size.to_int_size();
let pixmap_size = tree.size().to_int_size();
let mut pixmap = tiny_skia::Pixmap::new(pixmap_size.width(), pixmap_size.height()).unwrap();
tree.render(tiny_skia::Transform::default(), &mut pixmap.as_mut());
pixmap
.encode_png()
.map_err(|_| RenderError { message: Some("Failed to encode".to_string()) })
// Calculate center point for scaling
let center_x = pixmap_size.width() as f32 / 2.0;
let center_y = pixmap_size.height() as f32 / 2.0;
// Create transform that scales from center: translate to center, scale, translate back
let zoom = 0.90; // 10% zoom out from center
let render_ts = tiny_skia::Transform::from_translate(-center_x, -center_y)
.post_scale(zoom, zoom)
.post_translate(center_x, center_y);
resvg::render(&tree, render_ts, &mut pixmap.as_mut());
pixmap.encode_png().map_err(|_| RenderError {
message: Some("Failed to encode".to_string()),
})
}
}

153
src/render.rs Normal file
View File

@@ -0,0 +1,153 @@
use crate::error::{get_error_response, TimeBannerError};
use crate::raster::Rasterizer;
use crate::template::{render_template, OutputForm, RenderContext};
use axum::body::Bytes;
use axum::http::{header, StatusCode};
use axum::response::IntoResponse;
use chrono::{DateTime, Utc};
use std::io::Cursor;
/// Output format for rendered time banners.
#[derive(Debug, Clone)]
pub enum OutputFormat {
Svg,
Png,
}
impl OutputFormat {
/// Determines output format from file extension. Defaults to SVG for unknown extensions.
pub fn from_extension(ext: &str) -> Self {
match ext {
"png" => OutputFormat::Png,
_ => OutputFormat::Svg, // Default to SVG
}
}
pub fn from_mime_type(mime_type: &str) -> Self {
// TODO: Support mime types dynamically, proper header parsing
match mime_type {
"image/svg+xml" => OutputFormat::Svg,
"image/png" => OutputFormat::Png,
_ => OutputFormat::Svg, // Default to SVG
}
}
/// Returns the appropriate MIME type for HTTP responses.
pub fn mime_type(&self) -> &'static str {
match self {
OutputFormat::Svg => "image/svg+xml",
OutputFormat::Png => "image/png",
}
}
}
/// Converts SVG to the requested format. PNG requires rasterization.
pub fn handle_rasterize(data: String, format: &OutputFormat) -> Result<Bytes, TimeBannerError> {
match format {
OutputFormat::Svg => Ok(Bytes::from(data)),
OutputFormat::Png => {
let renderer = Rasterizer::new();
let raw_image = renderer.render(data.into_bytes());
if let Err(err) = raw_image {
return Err(TimeBannerError::RasterizeError(
err.message.unwrap_or_else(|| "Unknown error".to_string()),
));
}
Ok(Bytes::from(raw_image.unwrap()))
}
}
}
/// Main rendering pipeline: template → SVG → optional rasterization → HTTP response.
///
/// Takes a timestamp, display format, and file extension, then:
/// 1. Renders the time using a template
/// 2. Converts to the requested format (SVG or PNG)
/// 3. Returns an HTTP response with appropriate headers
pub fn render_time_response(
time: DateTime<Utc>,
output_form: OutputForm,
extension: &str,
) -> impl IntoResponse {
let output_format = OutputFormat::from_extension(extension);
// Build context for rendering
let context = RenderContext {
value: time,
output_form,
output_format: output_format.clone(),
timezone: None, // Default to UTC for now
format: None, // Use default format
now: None, // Use current time
};
// Render template
let rendered_template = match render_template(context) {
Ok(template) => template,
Err(e) => {
return get_error_response(TimeBannerError::RenderError(format!(
"Template rendering failed: {}",
e
)))
.into_response()
}
};
// Handle rasterization
match handle_rasterize(rendered_template, &output_format) {
Ok(bytes) => (
StatusCode::OK,
[(header::CONTENT_TYPE, output_format.mime_type())],
bytes,
)
.into_response(),
Err(e) => get_error_response(e).into_response(),
}
}
/// Generates PNG bytes for the favicon clock.
pub fn generate_favicon_png_bytes(time: DateTime<Utc>) -> Result<Vec<u8>, TimeBannerError> {
// Build context for rendering
let context = RenderContext {
value: time,
output_form: OutputForm::Clock,
output_format: OutputFormat::Png,
timezone: None,
format: None,
now: None,
};
// Render template to SVG
let rendered_template = render_template(context)
.map_err(|e| TimeBannerError::RenderError(format!("Template rendering failed: {}", e)))?;
// Convert SVG to PNG
let png_bytes = handle_rasterize(rendered_template, &OutputFormat::Png)?;
Ok(png_bytes.to_vec())
}
/// Converts PNG bytes to ICO format using the ico crate.
pub fn convert_png_to_ico(png_bytes: &[u8]) -> Result<Bytes, String> {
// Create a new, empty icon collection
let mut icon_dir = ico::IconDir::new(ico::ResourceType::Icon);
// Read PNG data from bytes
let cursor = Cursor::new(png_bytes);
let image =
ico::IconImage::read_png(cursor).map_err(|e| format!("Failed to read PNG data: {}", e))?;
// Add the image to the icon collection
icon_dir.add_entry(
ico::IconDirEntry::encode(&image)
.map_err(|e| format!("Failed to encode icon entry: {}", e))?,
);
// Write ICO data to a buffer
let mut ico_buffer = Vec::new();
icon_dir
.write(&mut ico_buffer)
.map_err(|e| format!("Failed to write ICO data: {}", e))?;
Ok(Bytes::from(ico_buffer))
}

View File

@@ -1,92 +1,110 @@
use axum::{http::StatusCode, response::IntoResponse};
use axum::body::{Bytes, Full};
use axum::extract::{Path};
use axum::http::{header};
use axum::response::Response;
use chrono::{DateTime, NaiveDateTime, Offset, Utc};
use crate::duration::parse_time_value;
use crate::error::{get_error_response, TimeBannerError};
use crate::render::{convert_png_to_ico, generate_favicon_png_bytes, render_time_response};
use crate::template::OutputForm;
use crate::utils::parse_path;
use axum::extract::ConnectInfo;
use axum::extract::Path;
use axum::http::{header, StatusCode};
use axum::response::IntoResponse;
use std::net::SocketAddr;
/// Root handler - redirects to current time in relative format.
pub async fn index_handler() -> impl IntoResponse {
let epoch_now = chrono::Utc::now().timestamp();
use crate::parse::split_on_extension;
use crate::raster::Rasterizer;
use crate::template::{OutputForm, render_template, RenderContext};
fn parse_path(path: &str) -> (&str, &str) {
split_on_extension(path)
.or_else(|| Some((path, "svg")))
.unwrap()
}
fn handle_rasterize(data: String, extension: &str) -> Result<(&str, Bytes), TimeBannerError> {
match extension {
"svg" => Ok(("image/svg+xml", Bytes::from(data))),
"png" => {
let renderer = Rasterizer::new();
let raw_image = renderer.render(data.into_bytes());
if raw_image.is_err() {
return Err(TimeBannerError::RasterizeError(raw_image.unwrap_err().message.unwrap_or("Unknown error".to_string())));
}
Ok(("image/x-png", Bytes::from(raw_image.unwrap())))
}
_ => Err(TimeBannerError::ParseError(format!("Unsupported extension: {}", extension)))
}
axum::response::Redirect::temporary(&format!("/relative/{epoch_now}")).into_response()
}
/// Handles `/relative/{time}` - displays time in relative format ("2 hours ago").
pub async fn relative_handler(Path(path): Path<String>) -> impl IntoResponse {
let (raw_time, extension) = parse_path(path.as_str());
}
let (raw_time, extension) = parse_path(&path);
pub async fn absolute_handler(Path(path): Path<String>) -> impl IntoResponse {
let (raw_time, extension) = parse_path(path.as_str());
}
// basic handler that responds with a static string
pub async fn implicit_handler(Path(path): Path<String>) -> impl IntoResponse {
// Get extension if available
let (raw_time, extension) = parse_path(path.as_str());
// Parse epoch
let parsed_epoch = raw_time.parse::<i64>();
if parsed_epoch.is_err() {
return get_error_response(TimeBannerError::ParseError("Input could not be parsed into integer.".to_string())).into_response();
}
// Convert epoch to DateTime
let naive_time = NaiveDateTime::from_timestamp_opt(parsed_epoch.unwrap(), 0);
if naive_time.is_none() {
return get_error_response(TimeBannerError::ParseError("Input was not a valid DateTime".to_string())).into_response();
}
let utc_time = DateTime::<Utc>::from_utc(naive_time.unwrap(), Utc);
// Build context for rendering
let context = RenderContext {
output_form: OutputForm::Relative,
value: utc_time,
tz_offset: utc_time.offset().fix(),
tz_name: "UTC",
view: "basic",
let time = match parse_time_value(raw_time) {
Ok(t) => t,
Err(e) => return get_error_response(e).into_response(),
};
let rendered_template = render_template(context);
if rendered_template.is_err() {
return Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Full::from(
format!("Template Could Not Be Rendered :: {}", rendered_template.err().unwrap())
))
.unwrap().into_response();
render_time_response(time, OutputForm::Relative, extension).into_response()
}
let rasterize_result = handle_rasterize(rendered_template.unwrap(), extension);
match rasterize_result {
Ok((mime_type, bytes)) => {
(StatusCode::OK, [(header::CONTENT_TYPE, mime_type)], bytes).into_response()
/// Handles `/absolute/{time}` - displays time in absolute format ("2025-01-17 14:30:00 UTC").
pub async fn absolute_handler(Path(path): Path<String>) -> impl IntoResponse {
let (raw_time, extension) = parse_path(&path);
let time = match parse_time_value(raw_time) {
Ok(t) => t,
Err(e) => return get_error_response(e).into_response(),
};
render_time_response(time, OutputForm::Absolute, extension).into_response()
}
Err(e) => get_error_response(e).into_response()
/// Handles `/{time}` - implicit absolute time display (same as absolute_handler).
pub async fn implicit_handler(Path(path): Path<String>) -> impl IntoResponse {
let (raw_time, extension) = parse_path(&path);
let time = match parse_time_value(raw_time) {
Ok(t) => t,
Err(e) => return get_error_response(e).into_response(),
};
render_time_response(time, OutputForm::Absolute, extension).into_response()
}
/// Handles `/favicon.ico` - generates a dynamic clock favicon showing the current time.
///
/// Logs the client IP address and returns an ICO image of an analog clock.
pub async fn favicon_handler(ConnectInfo(addr): ConnectInfo<SocketAddr>) -> impl IntoResponse {
let now = chrono::Utc::now();
// Log the IP address for the favicon request
tracing::info!("Favicon request from IP: {}", addr.ip());
// Generate PNG bytes directly for conversion
let png_bytes = match generate_favicon_png_bytes(now) {
Ok(bytes) => bytes,
Err(e) => return get_error_response(e).into_response(),
};
// Convert PNG to ICO using the ico crate
match convert_png_to_ico(&png_bytes) {
Ok(ico_bytes) => (
StatusCode::OK,
[(header::CONTENT_TYPE, "image/x-icon")],
ico_bytes,
)
.into_response(),
Err(e) => get_error_response(TimeBannerError::RenderError(format!(
"Failed to convert PNG to ICO: {}",
e
)))
.into_response(),
}
}
/// Handles `/favicon.png` - generates a dynamic clock favicon showing the current time.
///
/// Logs the client IP address and returns a PNG image of an analog clock.
pub async fn favicon_png_handler(ConnectInfo(addr): ConnectInfo<SocketAddr>) -> impl IntoResponse {
let now = chrono::Utc::now();
// Log the IP address for the favicon request
tracing::info!("Favicon PNG request from IP: {}", addr.ip());
// Generate PNG bytes directly
match generate_favicon_png_bytes(now) {
Ok(png_bytes) => (
StatusCode::OK,
[(header::CONTENT_TYPE, "image/png")],
png_bytes,
)
.into_response(),
Err(e) => get_error_response(e).into_response(),
}
}
/// Fallback handler for unmatched routes.
pub async fn fallback_handler() -> impl IntoResponse {
get_error_response(TimeBannerError::NotFound).into_response()
}

View File

@@ -1,47 +1,129 @@
use chrono::{DateTime, FixedOffset, Utc};
use timeago::Formatter;
use tera::{Context, Tera};
use chrono::{DateTime, Timelike, Utc};
use lazy_static::lazy_static;
use tera::{Context, Tera};
use timeago::Formatter;
use crate::render::OutputFormat;
lazy_static! {
/// Global Tera template engine instance.
static ref TEMPLATES: Tera = {
let mut _tera = match Tera::new("templates/**/*.svg") {
let template_pattern = if cfg!(debug_assertions) {
// Development: templates are in src/templates
"src/templates/**/*.svg"
} else {
// Production: templates are in /usr/src/app/templates (relative to working dir)
"templates/**/*.svg"
};
match Tera::new(template_pattern) {
Ok(t) => {
let names: Vec<&str> = t.get_template_names().collect();
println!("{} templates found ([{}]).", names.len(), names.join(", "));
t
},
}
Err(e) => {
println!("Parsing error(s): {}", e);
::std::process::exit(1);
}
};
_tera
}
};
}
/// Display format for time values.
pub enum OutputForm {
/// Relative display: "2 hours ago", "in 3 days"
Relative,
/// Absolute display: "2025-01-17 14:30:00 UTC"
Absolute,
/// Clock display: analog clock with hands showing the time
Clock,
}
pub struct RenderContext<'a> {
pub output_form: OutputForm,
/// Timezone specification formats (currently unused but reserved for future features).
pub enum TzForm {
Abbreviation(String), // e.g. "CST"
Iso(String), // e.g. "America/Chicago"
Offset(i32), // e.g. "-0600" as -21600
}
/// Context passed to template renderer containing all necessary data.
pub struct RenderContext {
pub value: DateTime<Utc>,
pub tz_offset: FixedOffset,
pub tz_name: &'a str,
pub view: &'a str,
pub output_form: OutputForm,
pub output_format: OutputFormat,
/// Target timezone (not yet implemented - defaults to UTC)
pub timezone: Option<TzForm>,
/// Custom time format string (not yet implemented)
pub format: Option<String>,
/// Reference time for relative calculations (not yet implemented - uses current time)
pub now: Option<i64>,
}
/// Calculates clock hand positions for a given time.
///
/// Returns (hour_x, hour_y, minute_x, minute_y) coordinates for SVG rendering.
/// Clock center is at (16, 16) with appropriate hand lengths for a 32x32 favicon.
fn calculate_clock_hands(time: DateTime<Utc>) -> (f64, f64, f64, f64) {
let hour = time.hour() as f64;
let minute = time.minute() as f64;
// Calculate angles (12 o'clock = 0°, clockwise)
let hour_angle = ((hour % 12.0) + minute / 60.0) * 30.0; // 30° per hour
let minute_angle = minute * 6.0; // 6° per minute
// Convert to radians and adjust for SVG coordinate system (0° at top)
let hour_rad = (hour_angle - 90.0).to_radians();
let minute_rad = (minute_angle - 90.0).to_radians();
// Clock center and hand lengths
let center_x = 16.0;
let center_y = 16.0;
let hour_length = 7.0; // Shorter hour hand
let minute_length = 11.0; // Longer minute hand
// Calculate end positions
let hour_x = center_x + hour_length * hour_rad.cos();
let hour_y = center_y + hour_length * hour_rad.sin();
let minute_x = center_x + minute_length * minute_rad.cos();
let minute_y = center_y + minute_length * minute_rad.sin();
(hour_x, hour_y, minute_x, minute_y)
}
/// Renders a time value using the appropriate template.
///
/// Uses different templates based on output form:
/// - Relative/Absolute: "basic.svg" with text content
/// - Clock: "clock.svg" with calculated hand positions
pub fn render_template(context: RenderContext) -> Result<String, tera::Error> {
let mut template_context = Context::new();
match context.output_form {
OutputForm::Relative => {
let formatter = Formatter::new();
template_context.insert("text", match context.output_form {
OutputForm::Relative => formatter.convert_chrono(context.value, Utc::now()),
OutputForm::Absolute => context.value.to_rfc3339()
}.as_str());
template_context.insert("text", &formatter.convert_chrono(context.value, Utc::now()));
TEMPLATES.render("basic.svg", &template_context)
}
OutputForm::Absolute => {
template_context.insert("text", &context.value.to_rfc3339());
TEMPLATES.render("basic.svg", &template_context)
}
OutputForm::Clock => {
let (hour_x, hour_y, minute_x, minute_y) = calculate_clock_hands(context.value);
// Format to 2 decimal places to avoid precision issues
let hour_x_str = format!("{:.2}", hour_x);
let hour_y_str = format!("{:.2}", hour_y);
let minute_x_str = format!("{:.2}", minute_x);
let minute_y_str = format!("{:.2}", minute_y);
template_context.insert("hour_x", &hour_x_str);
template_context.insert("hour_y", &hour_y_str);
template_context.insert("minute_x", &minute_x_str);
template_context.insert("minute_y", &minute_y_str);
TEMPLATES.render("clock.svg", &template_context)
}
}
}

View File

@@ -1,6 +1,6 @@
<svg width="512" height="34" xmlns="http://www.w3.org/2000/svg" font-family="Roboto Mono" font-size="27">
<text x="8" y="27">{{ text }}</text>
<style>
text
</style>
<svg xmlns="http://www.w3.org/2000/svg" font-family="Roboto Mono"
font-size="27">
<text text-anchor="start" dy="24">
{{ text }}
</text>
</svg>

Before

Width:  |  Height:  |  Size: 191 B

After

Width:  |  Height:  |  Size: 162 B

37
src/templates/clock.svg Normal file
View File

@@ -0,0 +1,37 @@
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<!-- Clock face -->
<circle cx="16" cy="16" r="15" fill="#ffffff" stroke="#000000" stroke-width="1" />
<!-- Hour markers -->
<g stroke="#000000" stroke-width="1">
<!-- 12 o'clock -->
<line x1="16" y1="2" x2="16" y2="5" />
<!-- 3 o'clock -->
<line x1="30" y1="16" x2="27" y2="16" />
<!-- 6 o'clock -->
<line x1="16" y1="30" x2="16" y2="27" />
<!-- 9 o'clock -->
<line x1="2" y1="16" x2="5" y2="16" />
<!-- Minor markers -->
<line x1="24.5" y1="4.5" x2="23.5" y2="5.5" />
<line x1="27.5" y1="7.5" x2="26.5" y2="8.5" />
<line x1="27.5" y1="24.5" x2="26.5" y2="23.5" />
<line x1="24.5" y1="27.5" x2="23.5" y2="26.5" />
<line x1="7.5" y1="27.5" x2="8.5" y2="26.5" />
<line x1="4.5" y1="24.5" x2="5.5" y2="23.5" />
<line x1="4.5" y1="7.5" x2="5.5" y2="8.5" />
<line x1="7.5" y1="4.5" x2="8.5" y2="5.5" />
</g>
<!-- Hour hand -->
<line x1="16" y1="16" x2="{{ hour_x }}" y2="{{ hour_y }}" stroke="#000000" stroke-width="2"
stroke-linecap="round" />
<!-- Minute hand -->
<line x1="16" y1="16" x2="{{ minute_x }}" y2="{{ minute_y }}" stroke="#000000"
stroke-width="1.5" stroke-linecap="round" />
<!-- Center dot -->
<circle cx="16" cy="16" r="1.5" fill="#000000" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

49
src/utils.rs Normal file
View File

@@ -0,0 +1,49 @@
//! General utility functions used across the application.
/// Splits a path on the last dot to extract filename and extension.
/// Returns None for dotfiles (paths starting with a dot).
pub fn split_on_extension(path: &str) -> Option<(&str, &str)> {
let split = path.rsplit_once('.')?;
// Check that the file is not a dotfile (.env)
if split.0.is_empty() {
return None;
}
Some(split)
}
/// Parses path into (filename, extension). Defaults to "svg" if no extension found.
pub fn parse_path(path: &str) -> (&str, &str) {
split_on_extension(path).unwrap_or((path, "svg"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_split_on_extension() {
assert_eq!(split_on_extension("file.txt"), Some(("file", "txt")));
assert_eq!(
split_on_extension("path/to/file.png"),
Some(("path/to/file", "png"))
);
assert_eq!(split_on_extension("noextension"), None);
assert_eq!(split_on_extension(".dotfile"), None); // dotfiles return None
assert_eq!(split_on_extension("file."), Some(("file", "")));
assert_eq!(
split_on_extension("file.name.ext"),
Some(("file.name", "ext"))
);
}
#[test]
fn test_parse_path() {
assert_eq!(parse_path("file.txt"), ("file", "txt"));
assert_eq!(parse_path("path/to/file.png"), ("path/to/file", "png"));
assert_eq!(parse_path("noextension"), ("noextension", "svg")); // default to svg
assert_eq!(parse_path(".dotfile"), (".dotfile", "svg")); // dotfiles get svg default
assert_eq!(parse_path("file."), ("file", ""));
}
}