mirror of
https://github.com/SeCherkasov/util-linux-cal.git
synced 2026-03-30 07:51:46 +03:00
Initial commit: Rust calendar utility
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
1335
Cargo.lock
generated
Normal file
1335
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
Cargo.toml
Normal file
29
Cargo.toml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
[package]
|
||||||
|
name = "cal"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "Calendar display utility"
|
||||||
|
authors = ["Se.Cherkasov@yahoo.com"]
|
||||||
|
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
chrono = { version = "0.4.43", features = ["unstable-locales"] }
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
unicode-width = "0.1"
|
||||||
|
terminal_size = "0.4"
|
||||||
|
libloading = { version = "0.8", optional = true }
|
||||||
|
shellexpand = { version = "3.1", optional = true }
|
||||||
|
libc = { version = "0.2", optional = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
plugins = ["dep:libloading", "dep:shellexpand", "dep:libc"]
|
||||||
|
default = ["plugins"]
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
assert_cmd = "2.0"
|
||||||
|
predicates = "3.0"
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
members = [
|
||||||
|
"plugins/holiday_highlighter",
|
||||||
|
]
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 SeCherkasov
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
187
README.md
Normal file
187
README.md
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
# cal
|
||||||
|
|
||||||
|
Terminal calendar — a Rust rewrite of the `cal` utility from util-linux.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Flexible display**: single month, three months, year, arbitrary number of months
|
||||||
|
- **Week start**: Monday `-m` (ISO) or Sunday `-s` (US)
|
||||||
|
- **Week numbers**: `-w` with numbering system choice (`--week-type iso` or `us`)
|
||||||
|
- **Julian days**: `-j` shows day of year instead of date
|
||||||
|
- **Vertical mode**: `-v` for compact day-by-column layout
|
||||||
|
- **Custom reform**: `--reform 1752|gregorian|iso|julian` for different calendar systems
|
||||||
|
- **Today highlight**: inverse color for current day
|
||||||
|
- **Weekend and holiday highlight**: colors for Saturday, Sunday, and official holidays
|
||||||
|
- **Plugins**: dynamic loading of holiday highlighter plugins via API
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
Binary will be in `target/release/cal`.
|
||||||
|
|
||||||
|
### Building with plugins
|
||||||
|
|
||||||
|
The holiday highlighter plugin is built in the workspace:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Plugin `libholiday_highlighter.so` will be in `target/release/`.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Basic usage
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|----------|
|
||||||
|
| `cal` | Current month |
|
||||||
|
| `cal 2026` | Entire year 2026 |
|
||||||
|
| `cal 2 2026` | February 2026 |
|
||||||
|
| `cal 15 9 2026` | September 2026 with 15th highlighted |
|
||||||
|
| `cal december 2025` | December 2025 (month names supported) |
|
||||||
|
|
||||||
|
### Display modes
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|----------|
|
||||||
|
| `cal -y` | Entire year (12 months) |
|
||||||
|
| `cal -Y` | Next 12 months from current |
|
||||||
|
| `cal -3` | Three months: previous, current, next |
|
||||||
|
| `cal -n 6` | Show 6 months |
|
||||||
|
| `cal --span -n 12` | 12 months centered on current month |
|
||||||
|
|
||||||
|
### Output format
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|----------|
|
||||||
|
| `cal -v` | Vertical mode (days in columns) |
|
||||||
|
| `cal -j` | Julian days (day of year 1-365/366) |
|
||||||
|
| `cal -w` | With week numbers |
|
||||||
|
| `cal --week-type us` | Weeks by US standard (starting Sunday) |
|
||||||
|
| `cal -c 2` | Force 2 columns for multi-month mode |
|
||||||
|
| `cal -c auto` | Auto-detect columns by terminal width |
|
||||||
|
|
||||||
|
### Calendar options
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|----------|
|
||||||
|
| `cal -m` | Week starts Monday (ISO, default) |
|
||||||
|
| `cal -s` | Week starts Sunday (US style) |
|
||||||
|
| `cal --reform 1752` | 1752 reform (skip Sep 3-13 in Great Britain) |
|
||||||
|
| `cal --reform gregorian` | Always Gregorian calendar |
|
||||||
|
| `cal --reform julian` | Always Julian calendar |
|
||||||
|
| `cal --iso` | ISO 8601 (alias for `--reform iso`) |
|
||||||
|
|
||||||
|
### Output and colors
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|----------|
|
||||||
|
| `cal --color` | Disable colors (monochrome output) |
|
||||||
|
| `cal -H` | Holiday highlight via isdayoff.ru API (requires plugin) |
|
||||||
|
|
||||||
|
### Combined examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Year with week numbers and holidays
|
||||||
|
cal -y -w -H
|
||||||
|
|
||||||
|
# Three months vertically with holidays
|
||||||
|
cal -3 -v -H
|
||||||
|
|
||||||
|
# 6 months in 2 columns
|
||||||
|
cal -n 6 -c 2
|
||||||
|
|
||||||
|
# February 2026 with Julian days
|
||||||
|
cal -j 2 2026
|
||||||
|
|
||||||
|
# Next 12 months centered
|
||||||
|
cal --span -Y
|
||||||
|
```
|
||||||
|
|
||||||
|
## Plugins
|
||||||
|
|
||||||
|
### Holiday Highlighter
|
||||||
|
|
||||||
|
Plugin for highlighting official holidays via isdayoff.ru API.
|
||||||
|
|
||||||
|
#### Supported countries
|
||||||
|
|
||||||
|
| Code | Country | Locales |
|
||||||
|
|------|---------|---------|
|
||||||
|
| RU | Russia | ru_RU, ru_BY, ru_KZ, ru_UZ, ru_LV |
|
||||||
|
| BY | Belarus | be_BY, ru_BY |
|
||||||
|
| KZ | Kazakhstan | kk_KZ, ru_KZ |
|
||||||
|
| US | USA | en_US, en |
|
||||||
|
| UZ | Uzbekistan | uz_UZ, ru_UZ |
|
||||||
|
| TR | Turkey | tr_TR |
|
||||||
|
| LV | Latvia | lv_LV, ru_LV |
|
||||||
|
|
||||||
|
Country is auto-detected from `LC_ALL`, `LC_TIME`, or `LANG`.
|
||||||
|
|
||||||
|
#### Day types
|
||||||
|
|
||||||
|
| Code | Meaning | Color |
|
||||||
|
|------|---------|-------|
|
||||||
|
| 0 | Working day | — |
|
||||||
|
| 1 | Weekend | Red |
|
||||||
|
| 2 | Shortened day | Teal |
|
||||||
|
| 8 | Public holiday | Red |
|
||||||
|
|
||||||
|
#### Installing the plugin
|
||||||
|
|
||||||
|
After building the workspace, the plugin is in `target/release/libholiday_highlighter.so`.
|
||||||
|
|
||||||
|
For system installation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# User-local
|
||||||
|
mkdir -p ~/.local/lib/cal/plugins
|
||||||
|
cp target/release/libholiday_highlighter.so ~/.local/lib/cal/plugins/
|
||||||
|
|
||||||
|
# System-wide (requires root)
|
||||||
|
sudo mkdir -p /usr/lib/cal/plugins
|
||||||
|
sudo cp target/release/libholiday_highlighter.so /usr/lib/cal/plugins/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### API
|
||||||
|
|
||||||
|
The plugin uses [isdayoff.ru API](https://isdayoff.ru/):
|
||||||
|
- `GET /api/getdata?year=YYYY&month=MM&pre=1` — monthly data
|
||||||
|
- `GET /api/getdata?year=YYYY&pre=1` — yearly data
|
||||||
|
|
||||||
|
Parameter `pre=1` includes pre-holiday shortened days information.
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `LC_ALL` | Priority locale for month and day names |
|
||||||
|
| `LC_TIME` | Locale for date formatting |
|
||||||
|
| `LANG` | Fallback locale |
|
||||||
|
| `CAL_TEST_TIME` | Fixed date for testing (format YYYY-MM-DD) |
|
||||||
|
|
||||||
|
## Localization
|
||||||
|
|
||||||
|
Month names are supported in Russian and English:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cal январь 2026
|
||||||
|
cal February 2026
|
||||||
|
cal 1 2026
|
||||||
|
```
|
||||||
|
|
||||||
|
Weekdays are abbreviated to 2 characters according to locale:
|
||||||
|
- **ru_RU**: Пн, Вт, Ср, Чт, Пт, Сб, Вс
|
||||||
|
- **en_US**: Mo, Tu, We, Th, Fr, Sa, Su
|
||||||
|
|
||||||
|
## Differences from util-linux cal
|
||||||
|
|
||||||
|
- Vertical mode support (`-v`)
|
||||||
|
- Dynamic plugins
|
||||||
|
- Automatic locale detection
|
||||||
|
- Flexible calendar reform configuration
|
||||||
|
- Color output with today/weekend/holiday highlights
|
||||||
187
README_ru.md
Normal file
187
README_ru.md
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
# cal
|
||||||
|
|
||||||
|
Календарь для терминала — переписанная на Rust версия утилиты `cal` из util-linux.
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- **Гибкое отображение**: один месяц, три месяца, год, произвольное количество месяцев
|
||||||
|
- **Неделя с понедельника или воскресенья**: `-m` (ISO) или `-s` (US)
|
||||||
|
- **Номера недель**: `-w` с выбором системы нумерации (`--week-type iso` или `us`)
|
||||||
|
- **Юлианские дни**: `-j` показывает день года вместо даты
|
||||||
|
- **Вертикальный режим**: `-v` для компактного отображения дней по колонкам
|
||||||
|
- **Кастомизация реформы**: `--reform 1752|gregorian|iso|julian` для разных календарных систем
|
||||||
|
- **Подсветка сегодня**: инверсия цвета для текущего дня
|
||||||
|
- **Подсветка выходных и праздников**: цвета для субботы, воскресенья и официальных праздников
|
||||||
|
- **Плагины**: динамическая загрузка плагинов для подсветки праздников через API
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
Бинарный файл появится в `target/release/cal`.
|
||||||
|
|
||||||
|
### Сборка с плагинами
|
||||||
|
|
||||||
|
Плагин подсветки праздников собирается в workspace:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Плагин `libholiday_highlighter.so` будет в `target/release/`.
|
||||||
|
|
||||||
|
## Команды
|
||||||
|
|
||||||
|
### Базовое использование
|
||||||
|
|
||||||
|
| Команда | Описание |
|
||||||
|
|---------|----------|
|
||||||
|
| `cal` | Текущий месяц |
|
||||||
|
| `cal 2026` | Весь 2026 год |
|
||||||
|
| `cal 2 2026` | Февраль 2026 |
|
||||||
|
| `cal 15 9 2026` | Сентябрь 2026 с выделением 15 числа |
|
||||||
|
| `cal декабрь 2025` | Декабрь 2025 (поддержка названий месяцев) |
|
||||||
|
|
||||||
|
### Режимы отображения
|
||||||
|
|
||||||
|
| Команда | Описание |
|
||||||
|
|---------|----------|
|
||||||
|
| `cal -y` | Весь год (12 месяцев) |
|
||||||
|
| `cal -Y` | Следующие 12 месяцев от текущего |
|
||||||
|
| `cal -3` | Три месяца: предыдущий, текущий, следующий |
|
||||||
|
| `cal -n 6` | Показать 6 месяцев |
|
||||||
|
| `cal --span -n 12` | 12 месяцев с центрированием на текущем |
|
||||||
|
|
||||||
|
### Формат вывода
|
||||||
|
|
||||||
|
| Команда | Описание |
|
||||||
|
|---------|----------|
|
||||||
|
| `cal -v` | Вертикальный режим (дни в колонках) |
|
||||||
|
| `cal -j` | Юлианские дни (день года 1-365/366) |
|
||||||
|
| `cal -w` | С номерами недель |
|
||||||
|
| `cal --week-type us` | Недели по US стандарту (с воскресенья) |
|
||||||
|
| `cal -c 2` | Принудительно 2 колонки для мульти-месячного режима |
|
||||||
|
| `cal -c auto` | Автоподбор колонок по ширине терминала |
|
||||||
|
|
||||||
|
### Календарные опции
|
||||||
|
|
||||||
|
| Команда | Описание |
|
||||||
|
|---------|----------|
|
||||||
|
| `cal -m` | Неделя с понедельника (ISO, по умолчанию) |
|
||||||
|
| `cal -s` | Неделя с воскресенья (US стиль) |
|
||||||
|
| `cal --reform 1752` | Реформа 1752 года (пропуск 3-13 сентября в Великобритании) |
|
||||||
|
| `cal --reform gregorian` | Всегда григорианский календарь |
|
||||||
|
| `cal --reform julian` | Всегда юлианский календарь |
|
||||||
|
| `cal --iso` | ISO 8601 (алиас для `--reform iso`) |
|
||||||
|
|
||||||
|
### Вывод и цвета
|
||||||
|
|
||||||
|
| Команда | Описание |
|
||||||
|
|---------|----------|
|
||||||
|
| `cal --color` | Отключить цвета (монохромный вывод) |
|
||||||
|
| `cal -H` | Подсветка праздников через isdayoff.ru API (требует плагин) |
|
||||||
|
|
||||||
|
### Комбинированные примеры
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Год с номерами недель и праздниками
|
||||||
|
cal -y -w -H
|
||||||
|
|
||||||
|
# Три месяца вертикально с праздниками
|
||||||
|
cal -3 -v -H
|
||||||
|
|
||||||
|
# 6 месяцев в 2 колонки
|
||||||
|
cal -n 6 -c 2
|
||||||
|
|
||||||
|
# Февраль 2026 с юлианскими днями
|
||||||
|
cal -j 2 2026
|
||||||
|
|
||||||
|
# Следующие 12 месяцев с центрированием
|
||||||
|
cal --span -Y
|
||||||
|
```
|
||||||
|
|
||||||
|
## Плагины
|
||||||
|
|
||||||
|
### Holiday Highlighter
|
||||||
|
|
||||||
|
Плагин для подсветки официальных праздников через API isdayoff.ru.
|
||||||
|
|
||||||
|
#### Поддерживаемые страны
|
||||||
|
|
||||||
|
| Код | Страна | Локали |
|
||||||
|
|-----|--------|--------|
|
||||||
|
| RU | Россия | ru_RU, ru_BY, ru_KZ, ru_UZ, ru_LV |
|
||||||
|
| BY | Беларусь | be_BY, ru_BY |
|
||||||
|
| KZ | Казахстан | kk_KZ, ru_KZ |
|
||||||
|
| US | США | en_US, en |
|
||||||
|
| UZ | Узбекистан | uz_UZ, ru_UZ |
|
||||||
|
| TR | Турция | tr_TR |
|
||||||
|
| LV | Латвия | lv_LV, ru_LV |
|
||||||
|
|
||||||
|
Страна определяется автоматически по `LC_ALL`, `LC_TIME` или `LANG`.
|
||||||
|
|
||||||
|
#### Типы дней
|
||||||
|
|
||||||
|
| Код | Значение | Цвет |
|
||||||
|
|-----|----------|------|
|
||||||
|
| 0 | Рабочий день | — |
|
||||||
|
| 1 | Выходной | Красный |
|
||||||
|
| 2 | Сокращённый день | Бирюзовый |
|
||||||
|
| 8 | Официальный праздник | Красный |
|
||||||
|
|
||||||
|
#### Установка плагина
|
||||||
|
|
||||||
|
После сборки workspace плагин находится в `target/release/libholiday_highlighter.so`.
|
||||||
|
|
||||||
|
Для системной установки:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Локально для пользователя
|
||||||
|
mkdir -p ~/.local/lib/cal/plugins
|
||||||
|
cp target/release/libholiday_highlighter.so ~/.local/lib/cal/plugins/
|
||||||
|
|
||||||
|
# Системно (требует root)
|
||||||
|
sudo mkdir -p /usr/lib/cal/plugins
|
||||||
|
sudo cp target/release/libholiday_highlighter.so /usr/lib/cal/plugins/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### API
|
||||||
|
|
||||||
|
Плагин использует [isdayoff.ru API](https://isdayoff.ru/):
|
||||||
|
- `GET /api/getdata?year=YYYY&month=MM&pre=1` — данные за месяц
|
||||||
|
- `GET /api/getdata?year=YYYY&pre=1` — данные за год
|
||||||
|
|
||||||
|
Параметр `pre=1` включает информацию о предпраздничных сокращённых днях.
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
| Переменная | Описание |
|
||||||
|
|------------|----------|
|
||||||
|
| `LC_ALL` | Приоритетная локаль для названий месяцев и дней |
|
||||||
|
| `LC_TIME` | Локаль для форматирования дат |
|
||||||
|
| `LANG` | Резервная локаль |
|
||||||
|
| `CAL_TEST_TIME` | Фиксированная дата для тестирования (формат YYYY-MM-DD) |
|
||||||
|
|
||||||
|
## Локализация
|
||||||
|
|
||||||
|
Поддерживаются названия месяцев на русском и английском:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cal январь 2026
|
||||||
|
cal February 2026
|
||||||
|
cal 1 2026
|
||||||
|
```
|
||||||
|
|
||||||
|
Дни недели сокращаются до 2 символов согласно локали:
|
||||||
|
- **ru_RU**: Пн, Вт, Ср, Чт, Пт, Сб, Вс
|
||||||
|
- **en_US**: Mo, Tu, We, Th, Fr, Sa, Su
|
||||||
|
|
||||||
|
## Отличия от util-linux cal
|
||||||
|
|
||||||
|
- Поддержка вертикального режима (`-v`)
|
||||||
|
- Динамические плагины
|
||||||
|
- Автоматическое определение локали
|
||||||
|
- Гибкая настройка календарной реформы
|
||||||
|
- Цветовой вывод с подсветкой сегодня/выходных/праздников
|
||||||
14
plugins/holiday_highlighter/Cargo.toml
Normal file
14
plugins/holiday_highlighter/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "holiday_highlighter"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "Holiday highlighting plugin for cal using isdayoff.ru API"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
name = "holiday_highlighter"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
chrono = { version = "0.4.43", features = ["unstable-locales"] }
|
||||||
|
ureq = "2.12"
|
||||||
|
libc = "0.2"
|
||||||
98
plugins/holiday_highlighter/README.md
Normal file
98
plugins/holiday_highlighter/README.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# Holiday Highlighter Plugin
|
||||||
|
|
||||||
|
Plugin for `cal` that adds holiday highlighting via isdayoff.ru API.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Automatic country detection** from system locale
|
||||||
|
- **Data caching** to reduce API requests
|
||||||
|
- **Multiple country support**: Russia, Belarus, Kazakhstan, USA, Uzbekistan, Turkey, Latvia
|
||||||
|
- **Day type data**: working days, weekends, shortened days, official holidays
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build entire workspace
|
||||||
|
cargo build --release --workspace
|
||||||
|
|
||||||
|
# Plugin only
|
||||||
|
cargo build --release -p holiday_highlighter
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### User-local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.local/lib/cal/plugins
|
||||||
|
cp target/release/libholiday_highlighter.so ~/.local/lib/cal/plugins/
|
||||||
|
```
|
||||||
|
|
||||||
|
### System-wide
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /usr/lib/cal/plugins
|
||||||
|
sudo cp target/release/libholiday_highlighter.so /usr/lib/cal/plugins/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Highlight holidays for current country
|
||||||
|
cal -H
|
||||||
|
|
||||||
|
# Year with holidays
|
||||||
|
cal -y -H
|
||||||
|
|
||||||
|
# Three months with holidays
|
||||||
|
cal -3 -H
|
||||||
|
```
|
||||||
|
|
||||||
|
## Supported countries
|
||||||
|
|
||||||
|
| Code | Country | Auto-detect locales |
|
||||||
|
|------|---------|---------------------|
|
||||||
|
| RU | Russia | ru_RU, ru_BY, ru_KZ, ru_UZ, ru_LV |
|
||||||
|
| BY | Belarus | be_BY, ru_BY |
|
||||||
|
| KZ | Kazakhstan | kk_KZ, ru_KZ |
|
||||||
|
| US | USA | en_US, en |
|
||||||
|
| UZ | Uzbekistan | uz_UZ, ru_UZ |
|
||||||
|
| TR | Turkey | tr_TR |
|
||||||
|
| LV | Latvia | lv_LV, ru_LV |
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
The plugin uses [isdayoff.ru API](https://isdayoff.ru/):
|
||||||
|
|
||||||
|
### Monthly request
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://isdayoff.ru/api/getdata?year=2026&month=01&pre=1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Yearly request
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://isdayoff.ru/api/getdata?year=2026&pre=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Parameter `pre=1` includes pre-holiday shortened days information.
|
||||||
|
|
||||||
|
## Data format
|
||||||
|
|
||||||
|
Each character in the response represents a day of the month:
|
||||||
|
|
||||||
|
| Character | Day type | Description |
|
||||||
|
|-----------|----------|-------------|
|
||||||
|
| `0` | Working | Regular working day |
|
||||||
|
| `1` | Weekend | Saturday or Sunday |
|
||||||
|
| `2` | Shortened | Pre-holiday shortened day |
|
||||||
|
| `8` | Holiday | Official public holiday |
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `LC_ALL` | Priority locale for country detection |
|
||||||
|
| `LC_TIME` | Locale for country detection |
|
||||||
|
| `LANG` | Fallback locale for country detection |
|
||||||
98
plugins/holiday_highlighter/README_ru.md
Normal file
98
plugins/holiday_highlighter/README_ru.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# Holiday Highlighter Plugin
|
||||||
|
|
||||||
|
Плагин для `cal`, добавляющий подсветку официальных праздников через API isdayoff.ru.
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- **Автоматическое определение страны** по системной локали
|
||||||
|
- **Кэширование данных** для уменьшения количества запросов к API
|
||||||
|
- **Поддержка нескольких стран**: Россия, Беларусь, Казахстан, США, Узбекистан, Турция, Латвия
|
||||||
|
- **Данные о типах дней**: рабочие, выходные, сокращённые, официальные праздники
|
||||||
|
|
||||||
|
## Сборка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Сборка всего workspace
|
||||||
|
cargo build --release --workspace
|
||||||
|
|
||||||
|
# Только плагин
|
||||||
|
cargo build --release -p holiday_highlighter
|
||||||
|
```
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
### Локально для пользователя
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.local/lib/cal/plugins
|
||||||
|
cp target/release/libholiday_highlighter.so ~/.local/lib/cal/plugins/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Системно
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /usr/lib/cal/plugins
|
||||||
|
sudo cp target/release/libholiday_highlighter.so /usr/lib/cal/plugins/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Подсветка праздников для текущей страны
|
||||||
|
cal -H
|
||||||
|
|
||||||
|
# Год с праздниками
|
||||||
|
cal -y -H
|
||||||
|
|
||||||
|
# Три месяца с праздниками
|
||||||
|
cal -3 -H
|
||||||
|
```
|
||||||
|
|
||||||
|
## Поддерживаемые страны
|
||||||
|
|
||||||
|
| Код | Страна | Локали для автоопределения |
|
||||||
|
|-----|--------|----------------------------|
|
||||||
|
| RU | Россия | ru_RU, ru_BY, ru_KZ, ru_UZ, ru_LV |
|
||||||
|
| BY | Беларусь | be_BY, ru_BY |
|
||||||
|
| KZ | Казахстан | kk_KZ, ru_KZ |
|
||||||
|
| US | США | en_US, en |
|
||||||
|
| UZ | Узбекистан | uz_UZ, ru_UZ |
|
||||||
|
| TR | Турция | tr_TR |
|
||||||
|
| LV | Латвия | lv_LV, ru_LV |
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Плагин использует [isdayoff.ru API](https://isdayoff.ru/):
|
||||||
|
|
||||||
|
### Запрос за месяц
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://isdayoff.ru/api/getdata?year=2026&month=01&pre=1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Запрос за год
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://isdayoff.ru/api/getdata?year=2026&pre=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Параметр `pre=1` включает информацию о предпраздничных сокращённых днях.
|
||||||
|
|
||||||
|
## Формат данных
|
||||||
|
|
||||||
|
Каждый символ в ответе представляет день месяца:
|
||||||
|
|
||||||
|
| Символ | Тип дня | Описание |
|
||||||
|
|--------|---------|----------|
|
||||||
|
| `0` | Рабочий | Обычный рабочий день |
|
||||||
|
| `1` | Выходной | Суббота или воскресенье |
|
||||||
|
| `2` | Сокращённый | Предпраздничный день |
|
||||||
|
| `8` | Праздник | Официальный государственный праздник |
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
| Переменная | Описание |
|
||||||
|
|------------|----------|
|
||||||
|
| `LC_ALL` | Приоритетная локаль для определения страны |
|
||||||
|
| `LC_TIME` | Локаль для определения страны |
|
||||||
|
| `LANG` | Резервная локаль для определения страны |
|
||||||
214
plugins/holiday_highlighter/src/lib.rs
Normal file
214
plugins/holiday_highlighter/src/lib.rs
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
//! Holiday Highlighter Plugin for cal.
|
||||||
|
//!
|
||||||
|
//! Fetches holiday data from isdayoff.ru API for multiple countries.
|
||||||
|
|
||||||
|
use libc::{c_char, c_int};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::ffi::{CStr, CString};
|
||||||
|
use std::sync::{LazyLock, Mutex};
|
||||||
|
|
||||||
|
pub const PLUGIN_NAME: &str = env!("CARGO_PKG_NAME");
|
||||||
|
pub const PLUGIN_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
|
const API_URL_YEAR: &str = "https://isdayoff.ru/api/getdata";
|
||||||
|
const API_URL_MONTH: &str = "https://isdayoff.ru/api/getdata";
|
||||||
|
|
||||||
|
/// Supported countries with their locale mappings.
|
||||||
|
const SUPPORTED_COUNTRIES: &[(&str, &[&str])] = &[
|
||||||
|
("RU", &["ru_RU", "ru_BY", "ru_KZ", "ru_UZ", "ru_LV"]),
|
||||||
|
("BY", &["be_BY", "ru_BY"]),
|
||||||
|
("KZ", &["kk_KZ", "ru_KZ"]),
|
||||||
|
("US", &["en_US", "en"]),
|
||||||
|
("UZ", &["uz_UZ", "ru_UZ"]),
|
||||||
|
("TR", &["tr_TR"]),
|
||||||
|
("LV", &["lv_LV", "ru_LV"]),
|
||||||
|
];
|
||||||
|
|
||||||
|
type CacheKey = (i32, u32, String);
|
||||||
|
static CACHE: LazyLock<Mutex<HashMap<CacheKey, String>>> =
|
||||||
|
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
|
/// Initialize the plugin (optional, cache is lazily initialized).
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn plugin_init() {}
|
||||||
|
|
||||||
|
/// Get plugin name (do not free returned pointer).
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn plugin_get_name() -> *const c_char {
|
||||||
|
static NAME: LazyLock<CString> = LazyLock::new(|| CString::new(PLUGIN_NAME).unwrap());
|
||||||
|
NAME.as_ptr()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get plugin version (do not free returned pointer).
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn plugin_get_version() -> *const c_char {
|
||||||
|
static VERSION: LazyLock<CString> = LazyLock::new(|| CString::new(PLUGIN_VERSION).unwrap());
|
||||||
|
VERSION.as_ptr()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get holiday data for a specific month.
|
||||||
|
///
|
||||||
|
/// Returns string where each character represents a day:
|
||||||
|
/// - '0' = working day, '1' = weekend, '2' = shortened, '8' = public holiday
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `country_code` must be a valid null-terminated C string.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn plugin_get_holidays(
|
||||||
|
year: c_int,
|
||||||
|
month: c_int,
|
||||||
|
country_code: *const c_char,
|
||||||
|
) -> *mut c_char {
|
||||||
|
let country = unsafe { parse_country(country_code) };
|
||||||
|
let key = (year, month as u32, country.clone());
|
||||||
|
|
||||||
|
if let Some(data) = CACHE.lock().unwrap().get(&key) {
|
||||||
|
return CString::new(data.as_str()).unwrap().into_raw();
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = fetch_holidays(year, month as u32, &country).unwrap_or_default();
|
||||||
|
CACHE.lock().unwrap().insert(key, data.clone());
|
||||||
|
|
||||||
|
CString::new(data).unwrap().into_raw()
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn parse_country(country_code: *const c_char) -> String {
|
||||||
|
unsafe {
|
||||||
|
CStr::from_ptr(country_code)
|
||||||
|
.to_str()
|
||||||
|
.unwrap_or("RU")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Free memory allocated by plugin_get_holidays.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `ptr` must be returned by `plugin_get_holidays`.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn plugin_free_holidays(ptr: *mut c_char) {
|
||||||
|
if !ptr.is_null() {
|
||||||
|
let _ = unsafe { CString::from_raw(ptr) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get country code from system locale.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn plugin_get_country_from_locale() -> *mut c_char {
|
||||||
|
let country = get_country_from_locale();
|
||||||
|
CString::new(country).unwrap().into_raw()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Free memory allocated by plugin_get_country_from_locale.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `ptr` must be returned by `plugin_get_country_from_locale`.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn plugin_free_country(ptr: *mut c_char) {
|
||||||
|
if !ptr.is_null() {
|
||||||
|
let _ = unsafe { CString::from_raw(ptr) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a specific day is a holiday.
|
||||||
|
///
|
||||||
|
/// Returns: 0=working, 1=weekend, 2=shortened, 8=public, -1=error
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `country_code` must be a valid null-terminated C string.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn plugin_is_holiday(
|
||||||
|
year: c_int,
|
||||||
|
month: c_int,
|
||||||
|
day: c_int,
|
||||||
|
country_code: *const c_char,
|
||||||
|
) -> c_int {
|
||||||
|
let country = unsafe { parse_country(country_code) };
|
||||||
|
let holidays = fetch_holidays(year, month as u32, &country);
|
||||||
|
|
||||||
|
match holidays {
|
||||||
|
Some(data) => {
|
||||||
|
let day_idx = (day - 1) as usize;
|
||||||
|
if day_idx < data.len() {
|
||||||
|
data.chars()
|
||||||
|
.nth(day_idx)
|
||||||
|
.and_then(|c| c.to_digit(10).map(|d| d as c_int))
|
||||||
|
.unwrap_or(-1)
|
||||||
|
} else {
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => -1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get holiday data for entire year.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `country_code` must be a valid null-terminated C string.
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub unsafe extern "C" fn plugin_get_year_holidays(
|
||||||
|
year: c_int,
|
||||||
|
country_code: *const c_char,
|
||||||
|
) -> *mut c_char {
|
||||||
|
let country = unsafe { parse_country(country_code) };
|
||||||
|
let data = fetch_holidays_year(year, &country).unwrap_or_default();
|
||||||
|
CString::new(data).unwrap().into_raw()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch holiday data for a month from isdayoff.ru API.
|
||||||
|
fn fetch_holidays(year: i32, month: u32, _country: &str) -> Option<String> {
|
||||||
|
let url = format!("{}?year={}&month={:02}&pre=1", API_URL_MONTH, year, month);
|
||||||
|
|
||||||
|
match ureq::get(&url).call() {
|
||||||
|
Ok(response) => response.into_string().ok(),
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch holiday data for entire year from isdayoff.ru API.
|
||||||
|
pub fn fetch_holidays_year(year: i32, _country: &str) -> Option<String> {
|
||||||
|
let url = format!("{}?year={}&pre=1", API_URL_YEAR, year);
|
||||||
|
|
||||||
|
match ureq::get(&url).call() {
|
||||||
|
Ok(response) => response.into_string().ok(),
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determine country code from system locale.
|
||||||
|
pub fn get_country_from_locale() -> String {
|
||||||
|
let locale = std::env::var("LC_ALL")
|
||||||
|
.or_else(|_| std::env::var("LC_TIME"))
|
||||||
|
.or_else(|_| std::env::var("LANG"))
|
||||||
|
.unwrap_or_else(|_| "en_US.UTF-8".to_string());
|
||||||
|
|
||||||
|
let locale_name = locale
|
||||||
|
.split('.')
|
||||||
|
.next()
|
||||||
|
.unwrap_or(&locale)
|
||||||
|
.split('@')
|
||||||
|
.next()
|
||||||
|
.unwrap_or(&locale);
|
||||||
|
|
||||||
|
// Match against supported countries
|
||||||
|
for (country, locales) in SUPPORTED_COUNTRIES {
|
||||||
|
for &supported_locale in *locales {
|
||||||
|
if locale_name == supported_locale {
|
||||||
|
return country.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract country code from locale (e.g., "en_US" -> "US")
|
||||||
|
if let Some(underscore_pos) = locale_name.find('_') {
|
||||||
|
let country_code = &locale_name[underscore_pos + 1..];
|
||||||
|
for (country, _) in SUPPORTED_COUNTRIES {
|
||||||
|
if *country == country_code {
|
||||||
|
return country_code.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"RU".to_string()
|
||||||
|
}
|
||||||
56
plugins/holiday_highlighter/tests/integration_tests.rs
Normal file
56
plugins/holiday_highlighter/tests/integration_tests.rs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
//! Integration tests for holiday_highlighter plugin.
|
||||||
|
|
||||||
|
use holiday_highlighter::{
|
||||||
|
PLUGIN_NAME, PLUGIN_VERSION, get_country_from_locale, plugin_get_name, plugin_get_version,
|
||||||
|
};
|
||||||
|
use std::ffi::CStr;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_country_from_locale_ru() {
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("LC_ALL", "ru_RU.UTF-8");
|
||||||
|
}
|
||||||
|
assert_eq!(get_country_from_locale(), "RU");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_country_from_locale_us() {
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("LC_ALL", "en_US.UTF-8");
|
||||||
|
}
|
||||||
|
assert_eq!(get_country_from_locale(), "US");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_country_from_locale_by() {
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("LC_ALL", "be_BY.UTF-8");
|
||||||
|
}
|
||||||
|
assert_eq!(get_country_from_locale(), "BY");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_country_from_locale_fallback() {
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("LC_ALL", "");
|
||||||
|
std::env::set_var("LC_TIME", "");
|
||||||
|
std::env::set_var("LANG", "");
|
||||||
|
}
|
||||||
|
assert_eq!(get_country_from_locale(), "RU");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_plugin_metadata_from_cargo() {
|
||||||
|
assert_eq!(PLUGIN_NAME, "holiday_highlighter");
|
||||||
|
assert_eq!(PLUGIN_VERSION, "0.1.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_plugin_get_name_version() {
|
||||||
|
unsafe {
|
||||||
|
let name = CStr::from_ptr(plugin_get_name()).to_str().unwrap();
|
||||||
|
let version = CStr::from_ptr(plugin_get_version()).to_str().unwrap();
|
||||||
|
assert_eq!(name, "holiday_highlighter");
|
||||||
|
assert_eq!(version, "0.1.0");
|
||||||
|
}
|
||||||
|
}
|
||||||
313
src/args.rs
Normal file
313
src/args.rs
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
//! Command-line argument parsing using clap.
|
||||||
|
//!
|
||||||
|
//! Arguments follow util-linux cal convention: `[[day] month] year`
|
||||||
|
|
||||||
|
use chrono::Datelike;
|
||||||
|
use clap::{Parser, ValueHint};
|
||||||
|
use std::io::IsTerminal;
|
||||||
|
|
||||||
|
use crate::types::{
|
||||||
|
COLOR_ENABLED_BY_DEFAULT, CalContext, ColumnsMode, GUTTER_WIDTH_REGULAR, ReformType, WeekType,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(name = "cal")]
|
||||||
|
#[command(about = "Displays calendar for specified month or year", long_about = None)]
|
||||||
|
#[command(version = "1.0.0")]
|
||||||
|
#[command(after_help = HELP_MESSAGE)]
|
||||||
|
pub struct Args {
|
||||||
|
/// Week starts on Sunday (default is Monday).
|
||||||
|
#[arg(short = 's', long, help_heading = "Calendar options")]
|
||||||
|
pub sunday: bool,
|
||||||
|
|
||||||
|
/// Week starts on Monday (default).
|
||||||
|
#[arg(short = 'm', long, help_heading = "Calendar options")]
|
||||||
|
pub monday: bool,
|
||||||
|
|
||||||
|
/// Display Julian days (day number in year).
|
||||||
|
#[arg(short = 'j', long, help_heading = "Calendar options")]
|
||||||
|
pub julian: bool,
|
||||||
|
|
||||||
|
/// Display week numbers.
|
||||||
|
#[arg(short = 'w', long, help_heading = "Calendar options")]
|
||||||
|
pub week_numbers: bool,
|
||||||
|
|
||||||
|
/// Week numbering system (iso or us).
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
default_value = "iso",
|
||||||
|
help_heading = "Calendar options",
|
||||||
|
value_name = "system"
|
||||||
|
)]
|
||||||
|
pub week_type: WeekType,
|
||||||
|
|
||||||
|
/// Display whole year.
|
||||||
|
#[arg(short = 'y', long, help_heading = "Display options")]
|
||||||
|
pub year: bool,
|
||||||
|
|
||||||
|
/// Display the next twelve months.
|
||||||
|
#[arg(short = 'Y', long = "twelve", help_heading = "Display options")]
|
||||||
|
pub twelve_months: bool,
|
||||||
|
|
||||||
|
/// Display three months (previous, current, next).
|
||||||
|
#[arg(short = '3', long = "three", help_heading = "Display options")]
|
||||||
|
pub three_months: bool,
|
||||||
|
|
||||||
|
/// Number of months to display.
|
||||||
|
#[arg(
|
||||||
|
short = 'n',
|
||||||
|
long = "months",
|
||||||
|
help_heading = "Display options",
|
||||||
|
value_name = "num"
|
||||||
|
)]
|
||||||
|
pub months_count: Option<u32>,
|
||||||
|
|
||||||
|
/// Show only a single month (default).
|
||||||
|
#[arg(short = '1', long = "one", help_heading = "Display options")]
|
||||||
|
pub one_month: bool,
|
||||||
|
|
||||||
|
/// Span the date when displaying multiple months (center around current month).
|
||||||
|
#[arg(short = 'S', long = "span", help_heading = "Display options")]
|
||||||
|
pub span: bool,
|
||||||
|
|
||||||
|
/// Gregorian reform date (1752|gregorian|iso|julian).
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
default_value = "1752",
|
||||||
|
help_heading = "Calendar options",
|
||||||
|
value_name = "val"
|
||||||
|
)]
|
||||||
|
pub reform: ReformType,
|
||||||
|
|
||||||
|
/// Use ISO 8601 reform (same as --reform iso).
|
||||||
|
#[arg(long, help_heading = "Calendar options")]
|
||||||
|
pub iso: bool,
|
||||||
|
|
||||||
|
/// Day (1-31) - optional, used with month and year.
|
||||||
|
#[arg(index = 1, default_value = None, value_name = "day", value_hint = ValueHint::Other)]
|
||||||
|
pub day_arg: Option<String>,
|
||||||
|
|
||||||
|
/// Month (1-12 or name) - optional, used with year.
|
||||||
|
#[arg(index = 2, default_value = None, value_name = "month", value_hint = ValueHint::Other)]
|
||||||
|
pub month_arg: Option<String>,
|
||||||
|
|
||||||
|
/// Year (1-9999).
|
||||||
|
#[arg(index = 3, default_value = None, value_name = "year", value_hint = ValueHint::Other)]
|
||||||
|
pub year_arg: Option<String>,
|
||||||
|
|
||||||
|
/// Disable colorized output.
|
||||||
|
#[arg(long, help_heading = "Output options")]
|
||||||
|
pub color: bool,
|
||||||
|
|
||||||
|
/// Number of columns for multiple months (or "auto" for terminal width).
|
||||||
|
#[arg(
|
||||||
|
short = 'c',
|
||||||
|
long = "columns",
|
||||||
|
help_heading = "Output options",
|
||||||
|
value_name = "width"
|
||||||
|
)]
|
||||||
|
pub columns: Option<String>,
|
||||||
|
|
||||||
|
/// Show days vertically (days in columns instead of rows).
|
||||||
|
#[arg(short = 'v', long, help_heading = "Output options")]
|
||||||
|
pub vertical: bool,
|
||||||
|
|
||||||
|
/// Highlight holidays using isdayoff.ru API (requires plugin).
|
||||||
|
///
|
||||||
|
/// **Note:** Build the workspace to include the plugin:
|
||||||
|
/// ```bash
|
||||||
|
/// cargo build --release --workspace
|
||||||
|
/// ```
|
||||||
|
/// The plugin file (`libholiday_highlighter.so`) must be in one of:
|
||||||
|
/// - `./target/release/` (after building)
|
||||||
|
/// - `~/.local/lib/cal/plugins/`
|
||||||
|
/// - `/usr/lib/cal/plugins/`
|
||||||
|
#[arg(short = 'H', long = "holidays", help_heading = "Output options")]
|
||||||
|
pub holidays: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Help message displayed with --help.
|
||||||
|
const HELP_MESSAGE: &str = "Display a calendar, or some part of it.
|
||||||
|
|
||||||
|
Without any arguments, display the current month.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
cal Display current month
|
||||||
|
cal -3 Display three months (prev, current, next)
|
||||||
|
cal -y Display the whole year
|
||||||
|
cal -Y Display next twelve months
|
||||||
|
cal 2 2026 Display February 2026
|
||||||
|
cal 2026 Display year 2026
|
||||||
|
cal --span -n 12 Display 12 months centered on current month
|
||||||
|
cal --color Disable colorized output
|
||||||
|
cal -H Highlight holidays (requires plugin, see --help)";
|
||||||
|
|
||||||
|
impl Args {
|
||||||
|
pub fn parse() -> Self {
|
||||||
|
Parser::parse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CalContext {
|
||||||
|
pub fn new(args: &Args) -> Result<Self, String> {
|
||||||
|
let today = get_today_date();
|
||||||
|
|
||||||
|
let color = !args.color && COLOR_ENABLED_BY_DEFAULT && std::io::stdout().is_terminal();
|
||||||
|
|
||||||
|
let columns = match args.columns.as_deref() {
|
||||||
|
Some("auto") | None => ColumnsMode::Auto,
|
||||||
|
Some(s) => {
|
||||||
|
let n = s
|
||||||
|
.parse::<u32>()
|
||||||
|
.map_err(|_| format!("Invalid columns value: {}", s))?;
|
||||||
|
if n == 0 {
|
||||||
|
return Err("Columns must be positive".to_string());
|
||||||
|
}
|
||||||
|
ColumnsMode::Fixed(n)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prevent conflicting display modes
|
||||||
|
let mode_count = [args.year, args.twelve_months, args.months_count.is_some()]
|
||||||
|
.iter()
|
||||||
|
.filter(|&&x| x)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
if mode_count > 1 {
|
||||||
|
return Err("Options -y, -Y, and -n are mutually exclusive".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(year_str) = &args.year_arg {
|
||||||
|
let year: i32 = year_str
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| format!("Invalid year value: {}", year_str))?;
|
||||||
|
if !(1..=9999).contains(&year) {
|
||||||
|
return Err(format!("Invalid year value: {} (must be 1-9999)", year));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertical mode uses narrower gutter for compact layout
|
||||||
|
let gutter_width = if args.vertical {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
GUTTER_WIDTH_REGULAR
|
||||||
|
};
|
||||||
|
|
||||||
|
// --iso overrides --reform
|
||||||
|
let reform_year = if args.iso {
|
||||||
|
ReformType::Iso.reform_year()
|
||||||
|
} else {
|
||||||
|
args.reform.reform_year()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(CalContext {
|
||||||
|
reform_year,
|
||||||
|
week_start: if args.sunday {
|
||||||
|
chrono::Weekday::Sun
|
||||||
|
} else {
|
||||||
|
chrono::Weekday::Mon
|
||||||
|
},
|
||||||
|
julian: args.julian,
|
||||||
|
week_numbers: args.week_numbers,
|
||||||
|
week_type: args.week_type,
|
||||||
|
color,
|
||||||
|
vertical: args.vertical,
|
||||||
|
today,
|
||||||
|
show_year_in_header: true,
|
||||||
|
gutter_width,
|
||||||
|
columns,
|
||||||
|
span: args.span,
|
||||||
|
#[cfg(feature = "plugins")]
|
||||||
|
holidays: args.holidays,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get today's date, respecting CAL_TEST_TIME environment variable for testing.
|
||||||
|
pub fn get_today_date() -> chrono::NaiveDate {
|
||||||
|
if let Ok(test_time) = std::env::var("CAL_TEST_TIME")
|
||||||
|
&& let Ok(date) = chrono::NaiveDate::parse_from_str(&test_time, "%Y-%m-%d")
|
||||||
|
{
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
chrono::Local::now().date_naive()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate display date from positional arguments.
|
||||||
|
///
|
||||||
|
/// Argument patterns:
|
||||||
|
/// - 1 arg: year (4 digits) or month (1-2 digits)
|
||||||
|
/// - 2 args: month year
|
||||||
|
/// - 3 args: day month year
|
||||||
|
pub fn get_display_date(args: &Args) -> Result<(i32, u32, Option<u32>), String> {
|
||||||
|
let today = get_today_date();
|
||||||
|
|
||||||
|
let day_provided = args.day_arg.is_some();
|
||||||
|
let month_provided = args.month_arg.is_some();
|
||||||
|
let year_provided = args.year_arg.is_some();
|
||||||
|
|
||||||
|
match (day_provided, month_provided, year_provided) {
|
||||||
|
// One argument: could be year (4 digits) or month (1-2 digits)
|
||||||
|
(true, false, false) => {
|
||||||
|
let val = args.day_arg.as_ref().unwrap();
|
||||||
|
if let Ok(num) = val.parse::<i32>() {
|
||||||
|
// 4 digits = year
|
||||||
|
if (1000..=9999).contains(&num) {
|
||||||
|
return Ok((num, today.month(), None));
|
||||||
|
}
|
||||||
|
// 1-2 digits = month
|
||||||
|
if (1..=12).contains(&num) {
|
||||||
|
return Ok((today.year(), num as u32, None));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Try parsing as month name
|
||||||
|
if let Some(month) = crate::formatter::parse_month(val) {
|
||||||
|
return Ok((today.year(), month, None));
|
||||||
|
}
|
||||||
|
Err(format!("Invalid argument: {}", val))
|
||||||
|
}
|
||||||
|
// Two arguments: month year (e.g., cal 2 2026)
|
||||||
|
(true, true, false) => {
|
||||||
|
let month = crate::formatter::parse_month(args.day_arg.as_ref().unwrap())
|
||||||
|
.ok_or_else(|| format!("Invalid month: {}", args.day_arg.as_ref().unwrap()))?;
|
||||||
|
let year = args
|
||||||
|
.month_arg
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.parse::<i32>()
|
||||||
|
.map_err(|_| format!("Invalid year: {}", args.month_arg.as_ref().unwrap()))?;
|
||||||
|
if !(1..=9999).contains(&year) {
|
||||||
|
return Err(format!("Invalid year: {} (must be 1-9999)", year));
|
||||||
|
}
|
||||||
|
Ok((year, month, None))
|
||||||
|
}
|
||||||
|
// Three arguments: day month year
|
||||||
|
(true, true, true) => {
|
||||||
|
let day = args
|
||||||
|
.day_arg
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.parse::<u32>()
|
||||||
|
.map_err(|_| format!("Invalid day: {}", args.day_arg.as_ref().unwrap()))?;
|
||||||
|
if !(1..=31).contains(&day) {
|
||||||
|
return Err(format!("Invalid day: {} (must be 1-31)", day));
|
||||||
|
}
|
||||||
|
let month = crate::formatter::parse_month(args.month_arg.as_ref().unwrap())
|
||||||
|
.ok_or_else(|| format!("Invalid month: {}", args.month_arg.as_ref().unwrap()))?;
|
||||||
|
let year = args
|
||||||
|
.year_arg
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.parse::<i32>()
|
||||||
|
.map_err(|_| format!("Invalid year: {}", args.year_arg.as_ref().unwrap()))?;
|
||||||
|
if !(1..=9999).contains(&year) {
|
||||||
|
return Err(format!("Invalid year: {} (must be 1-9999)", year));
|
||||||
|
}
|
||||||
|
Ok((year, month, Some(day)))
|
||||||
|
}
|
||||||
|
// No arguments: current month
|
||||||
|
(false, false, false) => Ok((today.year(), today.month(), None)),
|
||||||
|
// Invalid combinations
|
||||||
|
_ => Err("Invalid argument combination".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
186
src/calendar.rs
Normal file
186
src/calendar.rs
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
//! Calendar calculation logic using Zeller's algorithm and custom reform handling.
|
||||||
|
|
||||||
|
use chrono::{Datelike, NaiveDate, Weekday};
|
||||||
|
|
||||||
|
use crate::types::{
|
||||||
|
CELLS_PER_MONTH, CalContext, ColumnsMode, MonthData, REFORM_FIRST_DAY, REFORM_LAST_DAY,
|
||||||
|
REFORM_MONTH, REFORM_YEAR_GB, WeekType,
|
||||||
|
};
|
||||||
|
|
||||||
|
impl CalContext {
|
||||||
|
/// Check if a year is a leap year according to the calendar rules.
|
||||||
|
pub fn is_leap_year(&self, year: i32) -> bool {
|
||||||
|
if year < self.reform_year {
|
||||||
|
// Julian: every 4 years
|
||||||
|
year % 4 == 0
|
||||||
|
} else {
|
||||||
|
// Gregorian: divisible by 4, except centuries unless divisible by 400
|
||||||
|
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn days_in_month(&self, year: i32, month: u32) -> u32 {
|
||||||
|
match month {
|
||||||
|
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||||||
|
4 | 6 | 9 | 11 => 30,
|
||||||
|
2 if self.is_leap_year(year) => 29,
|
||||||
|
2 => 28,
|
||||||
|
_ => 30,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a date falls within the reform gap (September 3-13, 1752).
|
||||||
|
pub fn is_reform_gap(&self, year: i32, month: u32, day: u32) -> bool {
|
||||||
|
if self.reform_year != REFORM_YEAR_GB {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
year == REFORM_YEAR_GB
|
||||||
|
&& month == REFORM_MONTH
|
||||||
|
&& (REFORM_FIRST_DAY..=REFORM_LAST_DAY).contains(&day)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate weekday using Zeller's congruence algorithm.
|
||||||
|
pub fn first_day_of_month(&self, year: i32, month: u32) -> Weekday {
|
||||||
|
let m = if month < 3 { month + 12 } else { month };
|
||||||
|
let q: i32 = 1;
|
||||||
|
let year_i = if month < 3 { year - 1 } else { year };
|
||||||
|
let k: i32 = year_i % 100;
|
||||||
|
let j: i32 = year_i / 100;
|
||||||
|
|
||||||
|
let h = (q + (13 * (m as i32 + 1)) / 5 + k + k / 4 + j / 4 - 2 * j) % 7;
|
||||||
|
// h: 0=Sat, 1=Sun, 2=Mon, 3=Tue, 4=Wed, 5=Thu, 6=Fri
|
||||||
|
match h {
|
||||||
|
0 => Weekday::Sat,
|
||||||
|
1 => Weekday::Sun,
|
||||||
|
2 => Weekday::Mon,
|
||||||
|
3 => Weekday::Tue,
|
||||||
|
4 => Weekday::Wed,
|
||||||
|
5 => Weekday::Thu,
|
||||||
|
6 => Weekday::Fri,
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate day of year (Julian day number within the year).
|
||||||
|
pub fn day_of_year(&self, year: i32, month: u32, day: u32) -> u32 {
|
||||||
|
const DAYS_BEFORE_MONTH: [u32; 12] =
|
||||||
|
[0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
|
||||||
|
let mut doy = DAYS_BEFORE_MONTH[(month - 1) as usize] + day;
|
||||||
|
|
||||||
|
if month > 2 && self.is_leap_year(year) {
|
||||||
|
doy += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust for reform gap (11 days removed in September 1752)
|
||||||
|
if year == REFORM_YEAR_GB && month >= REFORM_MONTH {
|
||||||
|
doy = doy.saturating_sub(REFORM_LAST_DAY - REFORM_FIRST_DAY + 1);
|
||||||
|
}
|
||||||
|
doy
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn week_number(&self, year: i32, month: u32, day: u32) -> u32 {
|
||||||
|
match self.week_type {
|
||||||
|
WeekType::Iso => {
|
||||||
|
// ISO 8601: week starts Monday, week 1 contains first Thursday
|
||||||
|
let date = NaiveDate::from_ymd_opt(year, month, day).unwrap();
|
||||||
|
date.iso_week().week()
|
||||||
|
}
|
||||||
|
WeekType::Us => {
|
||||||
|
// US: week starts Sunday, week 1 contains January 1
|
||||||
|
let date = NaiveDate::from_ymd_opt(year, month, day).unwrap();
|
||||||
|
let jan1 = NaiveDate::from_ymd_opt(year, 1, 1).unwrap();
|
||||||
|
let days_since_jan1 = date.signed_duration_since(jan1).num_days() as u32;
|
||||||
|
let jan1_weekday = jan1.weekday().num_days_from_sunday();
|
||||||
|
((days_since_jan1 + jan1_weekday) / 7) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_weekend(&self, weekday: Weekday) -> bool {
|
||||||
|
matches!(weekday, Weekday::Sat | Weekday::Sun)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn months_per_row(&self) -> u32 {
|
||||||
|
match self.columns {
|
||||||
|
ColumnsMode::Fixed(n) => n,
|
||||||
|
ColumnsMode::Auto => {
|
||||||
|
// ~20 chars per month + gutter, clamp to 1-3 for readability
|
||||||
|
let month_width = 20 + self.gutter_width;
|
||||||
|
if let Some(term_width) = get_terminal_width() {
|
||||||
|
(term_width / month_width as u32).clamp(1, 3)
|
||||||
|
} else {
|
||||||
|
3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MonthData {
|
||||||
|
/// Build calendar data for a specific month.
|
||||||
|
pub fn new(ctx: &CalContext, year: i32, month: u32) -> Self {
|
||||||
|
let days_in_month = ctx.days_in_month(year, month);
|
||||||
|
let first_day = ctx.first_day_of_month(year, month);
|
||||||
|
|
||||||
|
// Calculate offset based on week start day
|
||||||
|
let offset = match ctx.week_start {
|
||||||
|
Weekday::Mon if first_day == Weekday::Sun => 6,
|
||||||
|
Weekday::Mon => first_day.num_days_from_monday() as usize,
|
||||||
|
Weekday::Sun => first_day.num_days_from_sunday() as usize,
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut days: Vec<Option<u32>> = Vec::with_capacity(CELLS_PER_MONTH);
|
||||||
|
let mut week_numbers: Vec<Option<u32>> = Vec::with_capacity(CELLS_PER_MONTH);
|
||||||
|
let mut weekdays: Vec<Option<Weekday>> = Vec::with_capacity(CELLS_PER_MONTH);
|
||||||
|
|
||||||
|
// Empty cells before first day
|
||||||
|
for _ in 0..offset {
|
||||||
|
days.push(None);
|
||||||
|
week_numbers.push(None);
|
||||||
|
weekdays.push(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill days, skipping reform gap
|
||||||
|
let mut current_weekday = first_day;
|
||||||
|
let mut day = 1;
|
||||||
|
while day <= days_in_month {
|
||||||
|
if ctx.is_reform_gap(year, month, day) {
|
||||||
|
// Skip reform gap (3-13 September 1752)
|
||||||
|
for _ in REFORM_FIRST_DAY..=REFORM_LAST_DAY {
|
||||||
|
days.push(None);
|
||||||
|
week_numbers.push(None);
|
||||||
|
weekdays.push(None);
|
||||||
|
current_weekday = current_weekday.succ();
|
||||||
|
}
|
||||||
|
day = REFORM_LAST_DAY + 1;
|
||||||
|
} else {
|
||||||
|
days.push(Some(day));
|
||||||
|
week_numbers.push(ctx.week_numbers.then(|| ctx.week_number(year, month, day)));
|
||||||
|
weekdays.push(Some(current_weekday));
|
||||||
|
current_weekday = current_weekday.succ();
|
||||||
|
day += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pad to 42 cells (6 weeks)
|
||||||
|
while days.len() < CELLS_PER_MONTH {
|
||||||
|
days.push(None);
|
||||||
|
week_numbers.push(None);
|
||||||
|
weekdays.push(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
MonthData {
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
days,
|
||||||
|
week_numbers,
|
||||||
|
weekdays,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get terminal width using terminal_size crate.
|
||||||
|
fn get_terminal_width() -> Option<u32> {
|
||||||
|
terminal_size::terminal_size().map(|(w, _)| w.0 as u32)
|
||||||
|
}
|
||||||
970
src/formatter.rs
Normal file
970
src/formatter.rs
Normal file
@@ -0,0 +1,970 @@
|
|||||||
|
//! Calendar formatting and display with localization and color support.
|
||||||
|
|
||||||
|
use chrono::{Datelike, Locale, NaiveDate, Weekday};
|
||||||
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
|
use crate::types::{
|
||||||
|
COLOR_RED, COLOR_RESET, COLOR_REVERSE, COLOR_SAND_YELLOW, COLOR_TEAL, CalContext,
|
||||||
|
GUTTER_WIDTH_YEAR, MonthData,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(feature = "plugins")]
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
#[cfg(feature = "plugins")]
|
||||||
|
static PLUGIN: Mutex<Option<crate::plugin_api::PluginHandle>> = Mutex::new(None);
|
||||||
|
|
||||||
|
#[cfg(feature = "plugins")]
|
||||||
|
static COUNTRY: Mutex<Option<String>> = Mutex::new(None);
|
||||||
|
|
||||||
|
/// Cache for holiday data: (year, month, data) or (year, 0, full_year_data)
|
||||||
|
#[cfg(feature = "plugins")]
|
||||||
|
static HOLIDAY_CACHE: Mutex<Option<(i32, u32, String)>> = Mutex::new(None);
|
||||||
|
|
||||||
|
/// Preload holiday data for entire year (called when -y flag is used)
|
||||||
|
#[cfg(feature = "plugins")]
|
||||||
|
pub fn preload_year_holidays(ctx: &CalContext, year: i32) {
|
||||||
|
if !ctx.holidays {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already cached
|
||||||
|
{
|
||||||
|
let cache_guard = HOLIDAY_CACHE.lock().unwrap();
|
||||||
|
if let Some((cached_year, cached_month, _)) = &*cache_guard
|
||||||
|
&& *cached_year == year
|
||||||
|
&& *cached_month == 0
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !init_plugin() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let plugin_guard = PLUGIN.lock().unwrap();
|
||||||
|
let country_guard = COUNTRY.lock().unwrap();
|
||||||
|
|
||||||
|
if let (Some(plugin), Some(country)) = (&*plugin_guard, &*country_guard)
|
||||||
|
&& let Some(data) = plugin.get_year_holidays(year, country)
|
||||||
|
{
|
||||||
|
let mut cache_guard = HOLIDAY_CACHE.lock().unwrap();
|
||||||
|
*cache_guard = Some((year, 0, data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "plugins"))]
|
||||||
|
pub fn preload_year_holidays(_ctx: &CalContext, _year: i32) {}
|
||||||
|
|
||||||
|
#[cfg(feature = "plugins")]
|
||||||
|
fn init_plugin() -> bool {
|
||||||
|
let mut plugin_guard = PLUGIN.lock().unwrap();
|
||||||
|
if plugin_guard.is_some() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(plugin) = crate::plugin_api::try_load_plugin() {
|
||||||
|
let country = plugin.get_country_from_locale();
|
||||||
|
let mut country_guard = COUNTRY.lock().unwrap();
|
||||||
|
*country_guard = Some(country.clone());
|
||||||
|
*plugin_guard = Some(plugin);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "plugins")]
|
||||||
|
fn get_holiday_code(ctx: &CalContext, year: i32, month: u32, day: u32) -> i32 {
|
||||||
|
if !ctx.holidays {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache (including full year cache with month=0)
|
||||||
|
{
|
||||||
|
let cache_guard = HOLIDAY_CACHE.lock().unwrap();
|
||||||
|
if let Some((cached_year, cached_month, data)) = &*cache_guard
|
||||||
|
&& *cached_year == year
|
||||||
|
{
|
||||||
|
let day_idx = if *cached_month == 0 {
|
||||||
|
// Full year data - calculate day of year
|
||||||
|
let date = chrono::NaiveDate::from_ymd_opt(year, month, day);
|
||||||
|
if let Some(d) = date {
|
||||||
|
d.ordinal() as usize - 1
|
||||||
|
} else {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
} else if *cached_month == month {
|
||||||
|
// Month data
|
||||||
|
(day - 1) as usize
|
||||||
|
} else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
if day_idx < data.len() {
|
||||||
|
return data
|
||||||
|
.chars()
|
||||||
|
.nth(day_idx)
|
||||||
|
.and_then(|c| c.to_digit(10).map(|d| d as i32))
|
||||||
|
.unwrap_or(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss - fetch data for the month
|
||||||
|
if !init_plugin() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let plugin_guard = PLUGIN.lock().unwrap();
|
||||||
|
let country_guard = COUNTRY.lock().unwrap();
|
||||||
|
|
||||||
|
if let (Some(plugin), Some(country)) = (&*plugin_guard, &*country_guard)
|
||||||
|
&& let Some(data) = plugin.get_holidays(year, month, country)
|
||||||
|
{
|
||||||
|
// Don't overwrite full year cache with month data
|
||||||
|
let mut cache_guard = HOLIDAY_CACHE.lock().unwrap();
|
||||||
|
let should_update = match &*cache_guard {
|
||||||
|
Some((cached_year, cached_month, _)) => !(*cached_year == year && *cached_month == 0),
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if should_update {
|
||||||
|
*cache_guard = Some((year, month, data.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let day_idx = (day - 1) as usize;
|
||||||
|
if day_idx < data.len() {
|
||||||
|
return data
|
||||||
|
.chars()
|
||||||
|
.nth(day_idx)
|
||||||
|
.and_then(|c| c.to_digit(10).map(|d| d as i32))
|
||||||
|
.unwrap_or(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "plugins"))]
|
||||||
|
fn get_holiday_code(_ctx: &CalContext, _year: i32, _month: u32, _day: u32) -> i32 {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "plugins")]
|
||||||
|
pub fn preload_holidays(ctx: &CalContext, year: i32, month: u32) {
|
||||||
|
if !ctx.holidays {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let cache_guard = HOLIDAY_CACHE.lock().unwrap();
|
||||||
|
if let Some((cached_year, cached_month, _)) = &*cache_guard
|
||||||
|
&& *cached_year == year
|
||||||
|
&& (*cached_month == month || *cached_month == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = get_holiday_code(ctx, year, month, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "plugins"))]
|
||||||
|
pub fn preload_holidays(_ctx: &CalContext, _year: i32, _month: u32) {}
|
||||||
|
|
||||||
|
/// Get system locale from environment (LC_ALL > LC_TIME > LANG > en_US).
|
||||||
|
pub fn get_system_locale() -> Locale {
|
||||||
|
std::env::var("LC_ALL")
|
||||||
|
.or_else(|_| std::env::var("LC_TIME"))
|
||||||
|
.or_else(|_| std::env::var("LANG"))
|
||||||
|
.unwrap_or_else(|_| "en_US.UTF-8".to_string())
|
||||||
|
.split('.')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("en_US")
|
||||||
|
.split('@')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("en_US")
|
||||||
|
.parse()
|
||||||
|
.unwrap_or(Locale::en_US)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get month name in nominative case for current locale.
|
||||||
|
pub fn get_month_name(month: u32) -> String {
|
||||||
|
let locale = get_system_locale();
|
||||||
|
|
||||||
|
match locale {
|
||||||
|
Locale::ru_RU => [
|
||||||
|
"Январь",
|
||||||
|
"Февраль",
|
||||||
|
"Март",
|
||||||
|
"Апрель",
|
||||||
|
"Май",
|
||||||
|
"Июнь",
|
||||||
|
"Июль",
|
||||||
|
"Август",
|
||||||
|
"Сентябрь",
|
||||||
|
"Октябрь",
|
||||||
|
"Ноябрь",
|
||||||
|
"Декабрь",
|
||||||
|
][(month - 1) as usize]
|
||||||
|
.to_string(),
|
||||||
|
Locale::uk_UA => [
|
||||||
|
"Січень",
|
||||||
|
"Лютий",
|
||||||
|
"Березень",
|
||||||
|
"Квітень",
|
||||||
|
"Травень",
|
||||||
|
"Червень",
|
||||||
|
"Липень",
|
||||||
|
"Серпень",
|
||||||
|
"Вересень",
|
||||||
|
"Жовтень",
|
||||||
|
"Листопад",
|
||||||
|
"Грудень",
|
||||||
|
][(month - 1) as usize]
|
||||||
|
.to_string(),
|
||||||
|
Locale::be_BY => [
|
||||||
|
"Студзень",
|
||||||
|
"Люты",
|
||||||
|
"Сакавік",
|
||||||
|
"Красавік",
|
||||||
|
"Май",
|
||||||
|
"Чэрвень",
|
||||||
|
"Ліпень",
|
||||||
|
"Жнівень",
|
||||||
|
"Верасень",
|
||||||
|
"Кастрычнік",
|
||||||
|
"Лістапад",
|
||||||
|
"Снежань",
|
||||||
|
][(month - 1) as usize]
|
||||||
|
.to_string(),
|
||||||
|
_ => {
|
||||||
|
let date = NaiveDate::from_ymd_opt(2000, month, 1).unwrap();
|
||||||
|
date.format_localized("%B", locale).to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse month from string (numeric 1-12 or name in English/Russian).
|
||||||
|
pub fn parse_month(s: &str) -> Option<u32> {
|
||||||
|
if let Ok(n) = s.parse::<u32>()
|
||||||
|
&& (1..=12).contains(&n)
|
||||||
|
{
|
||||||
|
return Some(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
let s_lower = s.to_lowercase();
|
||||||
|
let month_names: [(&str, u32); 35] = [
|
||||||
|
// English full names
|
||||||
|
("january", 1),
|
||||||
|
("february", 2),
|
||||||
|
("march", 3),
|
||||||
|
("april", 4),
|
||||||
|
("may", 5),
|
||||||
|
("june", 6),
|
||||||
|
("july", 7),
|
||||||
|
("august", 8),
|
||||||
|
("september", 9),
|
||||||
|
("october", 10),
|
||||||
|
("november", 11),
|
||||||
|
("december", 12),
|
||||||
|
// Russian full names
|
||||||
|
("январь", 1),
|
||||||
|
("февраль", 2),
|
||||||
|
("март", 3),
|
||||||
|
("апрель", 4),
|
||||||
|
("май", 5),
|
||||||
|
("июнь", 6),
|
||||||
|
("июль", 7),
|
||||||
|
("август", 8),
|
||||||
|
("сентябрь", 9),
|
||||||
|
("октябрь", 10),
|
||||||
|
("ноябрь", 11),
|
||||||
|
("декабрь", 12),
|
||||||
|
// English short forms
|
||||||
|
("jan", 1),
|
||||||
|
("feb", 2),
|
||||||
|
("mar", 3),
|
||||||
|
("apr", 4),
|
||||||
|
("jun", 6),
|
||||||
|
("jul", 7),
|
||||||
|
("aug", 8),
|
||||||
|
("sep", 9),
|
||||||
|
("oct", 10),
|
||||||
|
("nov", 11),
|
||||||
|
("dec", 12),
|
||||||
|
];
|
||||||
|
month_names
|
||||||
|
.iter()
|
||||||
|
.find(|(name, _)| *name == s_lower)
|
||||||
|
.map(|(_, num)| *num)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format month header with optional year and color.
|
||||||
|
pub fn format_month_header(
|
||||||
|
year: i32,
|
||||||
|
month: u32,
|
||||||
|
width: usize,
|
||||||
|
show_year: bool,
|
||||||
|
color: bool,
|
||||||
|
) -> String {
|
||||||
|
let month_name = get_month_name(month);
|
||||||
|
let header = if show_year {
|
||||||
|
format!("{} {}", month_name, year)
|
||||||
|
} else {
|
||||||
|
month_name
|
||||||
|
};
|
||||||
|
let centered = center_text(&header, width);
|
||||||
|
if color {
|
||||||
|
format!("{}{}{}", COLOR_TEAL, centered, COLOR_RESET)
|
||||||
|
} else {
|
||||||
|
centered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Center text within a specified width, accounting for Unicode character widths.
|
||||||
|
fn center_text(text: &str, width: usize) -> String {
|
||||||
|
let text_width = text.width();
|
||||||
|
if text_width >= width {
|
||||||
|
return text.to_string();
|
||||||
|
}
|
||||||
|
let total_padding = width - text_width;
|
||||||
|
let left_padding = total_padding.div_ceil(2);
|
||||||
|
let right_padding = total_padding - left_padding;
|
||||||
|
format!(
|
||||||
|
"{}{}{}",
|
||||||
|
" ".repeat(left_padding),
|
||||||
|
text,
|
||||||
|
" ".repeat(right_padding)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get weekday order based on week start day.
|
||||||
|
pub fn get_weekday_order(week_start: Weekday) -> [Weekday; 7] {
|
||||||
|
match week_start {
|
||||||
|
Weekday::Mon => [
|
||||||
|
Weekday::Mon,
|
||||||
|
Weekday::Tue,
|
||||||
|
Weekday::Wed,
|
||||||
|
Weekday::Thu,
|
||||||
|
Weekday::Fri,
|
||||||
|
Weekday::Sat,
|
||||||
|
Weekday::Sun,
|
||||||
|
],
|
||||||
|
Weekday::Sun => [
|
||||||
|
Weekday::Sun,
|
||||||
|
Weekday::Mon,
|
||||||
|
Weekday::Tue,
|
||||||
|
Weekday::Wed,
|
||||||
|
Weekday::Thu,
|
||||||
|
Weekday::Fri,
|
||||||
|
Weekday::Sat,
|
||||||
|
],
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get 2-character weekday abbreviation for current locale.
|
||||||
|
pub fn get_weekday_short_name(weekday: Weekday, locale: Locale) -> String {
|
||||||
|
let base_date = NaiveDate::from_ymd_opt(2000, 1, 3).unwrap();
|
||||||
|
let offset = weekday.num_days_from_monday() as i64;
|
||||||
|
let date = base_date + chrono::Duration::days(offset);
|
||||||
|
let day_name = date.format_localized("%a", locale).to_string();
|
||||||
|
day_name.chars().take(2).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format weekday header row with optional week numbers and color.
|
||||||
|
pub fn format_weekday_headers(ctx: &CalContext, week_numbers: bool) -> String {
|
||||||
|
let locale = get_system_locale();
|
||||||
|
let mut result = String::new();
|
||||||
|
|
||||||
|
if week_numbers {
|
||||||
|
result.push_str(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.julian {
|
||||||
|
result.push(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
let weekday_order = get_weekday_order(ctx.week_start);
|
||||||
|
|
||||||
|
if ctx.color {
|
||||||
|
result.push_str(COLOR_SAND_YELLOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i, &weekday) in weekday_order.iter().enumerate() {
|
||||||
|
let short_name = get_weekday_short_name(weekday, locale);
|
||||||
|
|
||||||
|
if ctx.julian {
|
||||||
|
if i < 6 {
|
||||||
|
result.push_str(&format!("{} ", short_name));
|
||||||
|
} else {
|
||||||
|
result.push_str(&format!(" {}", short_name));
|
||||||
|
}
|
||||||
|
} else if i < 6 {
|
||||||
|
result.push_str(&format!("{} ", short_name));
|
||||||
|
} else {
|
||||||
|
result.push_str(&short_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.color {
|
||||||
|
result.push_str(COLOR_RESET);
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format day cell with color highlighting.
|
||||||
|
///
|
||||||
|
/// Color priority: today > shortened day > weekend/holiday > regular
|
||||||
|
fn format_day(
|
||||||
|
ctx: &CalContext,
|
||||||
|
day: u32,
|
||||||
|
month: u32,
|
||||||
|
year: i32,
|
||||||
|
weekday: Weekday,
|
||||||
|
is_last: bool,
|
||||||
|
) -> String {
|
||||||
|
let is_today = ctx.color
|
||||||
|
&& ctx.today.day() == day
|
||||||
|
&& ctx.today.month() == month
|
||||||
|
&& ctx.today.year() == year;
|
||||||
|
|
||||||
|
let is_weekend = ctx.color && ctx.is_weekend(weekday);
|
||||||
|
let holiday_code = if ctx.color {
|
||||||
|
get_holiday_code(ctx, year, month, day)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let day_str = format!("{:>2}", day);
|
||||||
|
|
||||||
|
let formatted = if is_today {
|
||||||
|
format!("{}{}{}", COLOR_REVERSE, day_str, COLOR_RESET)
|
||||||
|
} else if holiday_code == 2 {
|
||||||
|
format!("{}{}{}", COLOR_TEAL, day_str, COLOR_RESET)
|
||||||
|
} else if is_weekend || holiday_code == 1 || holiday_code == 8 {
|
||||||
|
format!("{}{}{}", COLOR_RED, day_str, COLOR_RESET)
|
||||||
|
} else {
|
||||||
|
day_str
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_last {
|
||||||
|
formatted
|
||||||
|
} else {
|
||||||
|
format!("{} ", formatted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format month as grid of lines (horizontal layout).
|
||||||
|
pub fn format_month_grid(ctx: &CalContext, month: &MonthData) -> Vec<String> {
|
||||||
|
let mut lines = Vec::with_capacity(8);
|
||||||
|
|
||||||
|
let header_width = if ctx.julian {
|
||||||
|
27
|
||||||
|
} else if ctx.week_numbers {
|
||||||
|
23
|
||||||
|
} else {
|
||||||
|
20
|
||||||
|
};
|
||||||
|
|
||||||
|
let month_header = format_month_header(
|
||||||
|
month.year,
|
||||||
|
month.month,
|
||||||
|
header_width,
|
||||||
|
ctx.show_year_in_header,
|
||||||
|
ctx.color,
|
||||||
|
);
|
||||||
|
lines.push(month_header);
|
||||||
|
|
||||||
|
let weekday_header = format_weekday_headers(ctx, ctx.week_numbers);
|
||||||
|
lines.push(weekday_header);
|
||||||
|
|
||||||
|
let mut day_idx = 0;
|
||||||
|
let total_days = month.days.len();
|
||||||
|
|
||||||
|
// Generate 6 weeks of calendar
|
||||||
|
for _week in 0..6 {
|
||||||
|
let mut line = String::new();
|
||||||
|
|
||||||
|
if ctx.week_numbers {
|
||||||
|
let week_wn = (0..7)
|
||||||
|
.filter_map(|d| {
|
||||||
|
let idx = day_idx + d;
|
||||||
|
if idx < total_days {
|
||||||
|
month.week_numbers.get(idx).copied().flatten()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.next();
|
||||||
|
|
||||||
|
if let Some(wn) = week_wn {
|
||||||
|
line.push_str(&format!("{:>2} ", wn));
|
||||||
|
} else {
|
||||||
|
line.push_str(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for day_in_week in 0..7 {
|
||||||
|
if day_idx >= total_days {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let is_last = (day_in_week + 1) % 7 == 0;
|
||||||
|
|
||||||
|
if let Some(day) = month.days[day_idx] {
|
||||||
|
if ctx.julian {
|
||||||
|
let doy = ctx.day_of_year(month.year, month.month, day);
|
||||||
|
let doy_str = format!("{:>3}", doy);
|
||||||
|
if is_last {
|
||||||
|
line.push_str(&doy_str);
|
||||||
|
} else {
|
||||||
|
line.push_str(&format!("{} ", doy_str));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let weekday = month.weekdays[day_idx].unwrap();
|
||||||
|
line.push_str(&format_day(
|
||||||
|
ctx,
|
||||||
|
day,
|
||||||
|
month.month,
|
||||||
|
month.year,
|
||||||
|
weekday,
|
||||||
|
is_last,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else if ctx.julian {
|
||||||
|
if is_last {
|
||||||
|
line.push_str(" ");
|
||||||
|
} else {
|
||||||
|
line.push_str(" ");
|
||||||
|
}
|
||||||
|
} else if is_last {
|
||||||
|
line.push_str(" ");
|
||||||
|
} else {
|
||||||
|
line.push_str(" ");
|
||||||
|
}
|
||||||
|
day_idx += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(line);
|
||||||
|
|
||||||
|
if day_idx >= total_days {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print single month in horizontal (default) or vertical layout.
|
||||||
|
pub fn print_month(ctx: &CalContext, year: i32, month: u32) {
|
||||||
|
preload_holidays(ctx, year, month);
|
||||||
|
|
||||||
|
let month_data = MonthData::new(ctx, year, month);
|
||||||
|
if ctx.vertical {
|
||||||
|
print_month_vertical(ctx, &month_data, true);
|
||||||
|
} else {
|
||||||
|
let lines = format_month_grid(ctx, &month_data);
|
||||||
|
for line in lines {
|
||||||
|
println!("{}", line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print single month in vertical layout (days in columns).
|
||||||
|
pub fn print_month_vertical(ctx: &CalContext, month: &MonthData, is_first: bool) {
|
||||||
|
let month_name = get_month_name(month.month);
|
||||||
|
let header = if ctx.show_year_in_header {
|
||||||
|
format!("{} {}", month_name, month.year)
|
||||||
|
} else {
|
||||||
|
month_name.to_string()
|
||||||
|
};
|
||||||
|
let month_width = 18;
|
||||||
|
|
||||||
|
let padded_header = if is_first {
|
||||||
|
format!(
|
||||||
|
" {:<width$}{}",
|
||||||
|
header,
|
||||||
|
" ".repeat(ctx.gutter_width),
|
||||||
|
width = month_width
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{:<width$}{}",
|
||||||
|
header,
|
||||||
|
" ".repeat(ctx.gutter_width),
|
||||||
|
width = month_width
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
if ctx.color {
|
||||||
|
println!("{}{}{}", COLOR_TEAL, padded_header, COLOR_RESET);
|
||||||
|
} else {
|
||||||
|
println!("{}", padded_header);
|
||||||
|
}
|
||||||
|
|
||||||
|
let locale = get_system_locale();
|
||||||
|
let weekday_order = get_weekday_order(ctx.week_start);
|
||||||
|
let weekday_names: Vec<String> = weekday_order
|
||||||
|
.iter()
|
||||||
|
.map(|&w| get_weekday_short_name(w, locale))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (row, weekday) in weekday_order.iter().enumerate() {
|
||||||
|
let day_short = &weekday_names[row];
|
||||||
|
if ctx.color {
|
||||||
|
print!("{}{}{}", COLOR_SAND_YELLOW, day_short, COLOR_RESET);
|
||||||
|
} else {
|
||||||
|
print!("{}", day_short);
|
||||||
|
}
|
||||||
|
|
||||||
|
for week in 0..6 {
|
||||||
|
let day_idx = (*weekday as usize) + 7 * week;
|
||||||
|
if day_idx < month.days.len() {
|
||||||
|
if let Some(day) = month.days[day_idx] {
|
||||||
|
print_day_vertical(ctx, day, month, *weekday);
|
||||||
|
} else {
|
||||||
|
print!(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print day cell in vertical layout with color highlighting.
|
||||||
|
fn print_day_vertical(ctx: &CalContext, day: u32, month: &MonthData, weekday: Weekday) {
|
||||||
|
let is_today = ctx.color
|
||||||
|
&& ctx.today.day() == day
|
||||||
|
&& ctx.today.month() == month.month
|
||||||
|
&& ctx.today.year() == month.year;
|
||||||
|
|
||||||
|
let is_weekend = ctx.color && ctx.is_weekend(weekday);
|
||||||
|
let holiday_code = if ctx.color {
|
||||||
|
get_holiday_code(ctx, month.year, month.month, day)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let day_str = day.to_string();
|
||||||
|
let padding = 3 - day_str.len();
|
||||||
|
|
||||||
|
let formatted = if is_today {
|
||||||
|
format!(
|
||||||
|
"{}{}{}{}",
|
||||||
|
" ".repeat(padding),
|
||||||
|
COLOR_REVERSE,
|
||||||
|
day,
|
||||||
|
COLOR_RESET
|
||||||
|
)
|
||||||
|
} else if holiday_code == 2 {
|
||||||
|
format!(
|
||||||
|
"{}{}{}{}",
|
||||||
|
" ".repeat(padding),
|
||||||
|
COLOR_TEAL,
|
||||||
|
day,
|
||||||
|
COLOR_RESET
|
||||||
|
)
|
||||||
|
} else if is_weekend || holiday_code == 1 || holiday_code == 8 {
|
||||||
|
format!("{}{}{}{}", " ".repeat(padding), COLOR_RED, day, COLOR_RESET)
|
||||||
|
} else {
|
||||||
|
format!("{:>3}", day)
|
||||||
|
};
|
||||||
|
print!("{}", formatted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print three months side by side (prev, current, next).
|
||||||
|
pub fn print_three_months(ctx: &CalContext, year: i32, month: u32) {
|
||||||
|
let prev_month = if month == 1 { 12 } else { month - 1 };
|
||||||
|
let prev_year = if month == 1 { year - 1 } else { year };
|
||||||
|
let next_month = if month == 12 { 1 } else { month + 1 };
|
||||||
|
let next_year = if month == 12 { year + 1 } else { year };
|
||||||
|
|
||||||
|
preload_holidays(ctx, prev_year, prev_month);
|
||||||
|
preload_holidays(ctx, year, month);
|
||||||
|
preload_holidays(ctx, next_year, next_month);
|
||||||
|
|
||||||
|
let months = vec![
|
||||||
|
MonthData::new(ctx, prev_year, prev_month),
|
||||||
|
MonthData::new(ctx, year, month),
|
||||||
|
MonthData::new(ctx, next_year, next_month),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ctx.vertical {
|
||||||
|
print_three_months_vertical(ctx, &months);
|
||||||
|
} else {
|
||||||
|
print_months_side_by_side(ctx, &months);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print multiple months side by side in horizontal layout.
|
||||||
|
pub fn print_months_side_by_side(ctx: &CalContext, months: &[MonthData]) {
|
||||||
|
let grids: Vec<Vec<String>> = months.iter().map(|m| format_month_grid(ctx, m)).collect();
|
||||||
|
let max_height = grids.iter().map(|g| g.len()).max().unwrap_or(0);
|
||||||
|
|
||||||
|
let month_width: usize = if ctx.julian {
|
||||||
|
27
|
||||||
|
} else if ctx.week_numbers {
|
||||||
|
23
|
||||||
|
} else {
|
||||||
|
20
|
||||||
|
};
|
||||||
|
|
||||||
|
for row in 0..max_height {
|
||||||
|
let mut line = String::new();
|
||||||
|
for (i, grid) in grids.iter().enumerate() {
|
||||||
|
if row < grid.len() {
|
||||||
|
let text = &grid[row];
|
||||||
|
let text_width = text.width();
|
||||||
|
line.push_str(text);
|
||||||
|
let padding = month_width.saturating_sub(text_width);
|
||||||
|
for _ in 0..padding {
|
||||||
|
line.push(' ');
|
||||||
|
}
|
||||||
|
if i < grids.len() - 1 {
|
||||||
|
for _ in 0..ctx.gutter_width {
|
||||||
|
line.push(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let width = if i < grids.len() - 1 {
|
||||||
|
month_width + ctx.gutter_width
|
||||||
|
} else {
|
||||||
|
month_width
|
||||||
|
};
|
||||||
|
for _ in 0..width {
|
||||||
|
line.push(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!("{}", line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print all 12 months of a year.
|
||||||
|
pub fn print_year(ctx: &CalContext, year: i32) {
|
||||||
|
if ctx.vertical {
|
||||||
|
println!("{}", center_text(&year.to_string(), 62));
|
||||||
|
} else {
|
||||||
|
println!("{}", center_text(&year.to_string(), 66));
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
|
||||||
|
if ctx.holidays {
|
||||||
|
preload_year_holidays(ctx, year);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut month_ctx = ctx.clone();
|
||||||
|
month_ctx.show_year_in_header = false;
|
||||||
|
month_ctx.gutter_width = if ctx.vertical { 1 } else { GUTTER_WIDTH_YEAR };
|
||||||
|
|
||||||
|
// Group months into rows of 3
|
||||||
|
let mut month_rows = Vec::new();
|
||||||
|
for month_row in 0..4 {
|
||||||
|
let mut months = Vec::new();
|
||||||
|
for col in 0..3 {
|
||||||
|
let month = (month_row * 3 + col + 1) as u32;
|
||||||
|
if month <= 12 {
|
||||||
|
months.push(MonthData::new(&month_ctx, year, month));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !months.is_empty() {
|
||||||
|
month_rows.push(months);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.vertical {
|
||||||
|
for months in month_rows.iter() {
|
||||||
|
print_three_months_vertical(&month_ctx, months);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for months in month_rows.iter() {
|
||||||
|
print_months_side_by_side(&month_ctx, months);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print three months in vertical layout.
|
||||||
|
pub fn print_three_months_vertical(ctx: &CalContext, months: &[MonthData]) {
|
||||||
|
let month_width = 18;
|
||||||
|
|
||||||
|
// Print headers
|
||||||
|
for (i, month) in months.iter().enumerate() {
|
||||||
|
let month_name = get_month_name(month.month);
|
||||||
|
let header = if ctx.show_year_in_header {
|
||||||
|
format!("{} {}", month_name, month.year)
|
||||||
|
} else {
|
||||||
|
month_name.to_string()
|
||||||
|
};
|
||||||
|
let padded_header = if i == 0 {
|
||||||
|
format!(
|
||||||
|
" {:<width$}{}",
|
||||||
|
header,
|
||||||
|
" ".repeat(ctx.gutter_width),
|
||||||
|
width = month_width
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{:<width$}{}",
|
||||||
|
header,
|
||||||
|
" ".repeat(ctx.gutter_width),
|
||||||
|
width = month_width
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if ctx.color {
|
||||||
|
print!("{}{}{}", COLOR_TEAL, padded_header, COLOR_RESET);
|
||||||
|
} else {
|
||||||
|
print!("{}", padded_header);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let locale = get_system_locale();
|
||||||
|
let weekday_order = get_weekday_order(ctx.week_start);
|
||||||
|
let weekday_names: Vec<String> = weekday_order
|
||||||
|
.iter()
|
||||||
|
.map(|&w| get_weekday_short_name(w, locale))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (row, &weekday) in weekday_order.iter().enumerate() {
|
||||||
|
let day_short = &weekday_names[row];
|
||||||
|
if ctx.color {
|
||||||
|
print!("{}{}{}", COLOR_SAND_YELLOW, day_short, COLOR_RESET);
|
||||||
|
} else {
|
||||||
|
print!("{}", day_short);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (month_idx, month) in months.iter().enumerate() {
|
||||||
|
if month_idx > 0 {
|
||||||
|
for _ in 0..ctx.gutter_width {
|
||||||
|
print!(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for week in 0..6 {
|
||||||
|
let day_idx = (weekday as usize) + 7 * week;
|
||||||
|
if day_idx < month.days.len() {
|
||||||
|
if let Some(day) = month.days[day_idx] {
|
||||||
|
print_day_vertical(ctx, day, month, weekday);
|
||||||
|
} else {
|
||||||
|
print!(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print 12 months starting from a given month (--twelve mode).
|
||||||
|
pub fn print_twelve_months(ctx: &CalContext, start_year: i32, start_month: u32) {
|
||||||
|
// Preload holiday data for all 12 months
|
||||||
|
if ctx.holidays {
|
||||||
|
for i in 0..12 {
|
||||||
|
let mut month = start_month + i;
|
||||||
|
let mut year = start_year;
|
||||||
|
while month > 12 {
|
||||||
|
month -= 12;
|
||||||
|
year += 1;
|
||||||
|
}
|
||||||
|
preload_holidays(ctx, year, month);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut month_ctx = ctx.clone();
|
||||||
|
month_ctx.show_year_in_header = true;
|
||||||
|
month_ctx.gutter_width = GUTTER_WIDTH_YEAR;
|
||||||
|
|
||||||
|
let months = (0..12)
|
||||||
|
.map(|i| {
|
||||||
|
let mut month = start_month + i;
|
||||||
|
let mut year = start_year;
|
||||||
|
while month > 12 {
|
||||||
|
month -= 12;
|
||||||
|
year += 1;
|
||||||
|
}
|
||||||
|
MonthData::new(&month_ctx, year, month)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
if ctx.vertical {
|
||||||
|
for month_data in &months {
|
||||||
|
print_month_vertical(&month_ctx, month_data, true);
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for chunk in months.chunks(3) {
|
||||||
|
print_months_side_by_side(&month_ctx, chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print a specified number of months (-n mode).
|
||||||
|
pub fn print_months_count(
|
||||||
|
ctx: &CalContext,
|
||||||
|
start_year: i32,
|
||||||
|
start_month: u32,
|
||||||
|
count: u32,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let months_per_row = ctx.months_per_row();
|
||||||
|
|
||||||
|
// Calculate start month for span mode (center around current month)
|
||||||
|
let (actual_start_year, actual_start_month) = if ctx.span && count > 1 {
|
||||||
|
let total_months = start_year * 12 + (start_month - 1) as i32;
|
||||||
|
let half = (count as i32 - 1) / 2;
|
||||||
|
let start = total_months - half;
|
||||||
|
let year = start.div_euclid(12);
|
||||||
|
let month = (start.rem_euclid(12) + 1) as u32;
|
||||||
|
(year, month)
|
||||||
|
} else {
|
||||||
|
(start_year, start_month)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Preload holiday data for all months
|
||||||
|
if ctx.holidays {
|
||||||
|
for i in 0..count {
|
||||||
|
let mut month = actual_start_month + i;
|
||||||
|
let mut year = actual_start_year;
|
||||||
|
while month > 12 {
|
||||||
|
month -= 12;
|
||||||
|
year += 1;
|
||||||
|
}
|
||||||
|
while month < 1 {
|
||||||
|
month += 12;
|
||||||
|
year -= 1;
|
||||||
|
}
|
||||||
|
preload_holidays(ctx, year, month);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let months = (0..count)
|
||||||
|
.map(|i| {
|
||||||
|
let mut month = actual_start_month + i;
|
||||||
|
let mut year = actual_start_year;
|
||||||
|
while month > 12 {
|
||||||
|
month -= 12;
|
||||||
|
year += 1;
|
||||||
|
}
|
||||||
|
while month < 1 {
|
||||||
|
month += 12;
|
||||||
|
year -= 1;
|
||||||
|
}
|
||||||
|
MonthData::new(ctx, year, month)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
if ctx.vertical {
|
||||||
|
for month_data in &months {
|
||||||
|
print_month_vertical(ctx, month_data, true);
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for chunk in months.chunks(months_per_row as usize) {
|
||||||
|
print_months_side_by_side(ctx, chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
15
src/lib.rs
Normal file
15
src/lib.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
//! Calendar display utility with support for multiple calendar systems.
|
||||||
|
//!
|
||||||
|
//! Features:
|
||||||
|
//! - Gregorian and Julian calendar support
|
||||||
|
//! - Customizable week start (Monday/Sunday)
|
||||||
|
//! - Week numbers and Julian day display
|
||||||
|
//! - Plugin system for holiday highlighting
|
||||||
|
|
||||||
|
pub mod args;
|
||||||
|
pub mod calendar;
|
||||||
|
pub mod formatter;
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
#[cfg(feature = "plugins")]
|
||||||
|
pub mod plugin_api;
|
||||||
45
src/main.rs
Normal file
45
src/main.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
//! Calendar CLI application.
|
||||||
|
//!
|
||||||
|
//! # Usage
|
||||||
|
//! ```ignore
|
||||||
|
//! cal // Current month
|
||||||
|
//! cal 2026 // Year 2026
|
||||||
|
//! cal 2 2026 // February 2026
|
||||||
|
//! cal -3 // Three months
|
||||||
|
//! cal -y // Whole year
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use cal::args::{Args, get_display_date};
|
||||||
|
use cal::formatter::{
|
||||||
|
print_month, print_months_count, print_three_months, print_twelve_months, print_year,
|
||||||
|
};
|
||||||
|
use cal::types::CalContext;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args = Args::parse();
|
||||||
|
|
||||||
|
if let Err(e) = run(&args) {
|
||||||
|
eprintln!("cal: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(args: &Args) -> Result<(), String> {
|
||||||
|
let ctx = CalContext::new(args)?;
|
||||||
|
let (year, month, _day) = get_display_date(args)?;
|
||||||
|
|
||||||
|
// Display mode priority: year > twelve_months > three_months > months_count > single
|
||||||
|
if args.year {
|
||||||
|
print_year(&ctx, year);
|
||||||
|
} else if args.twelve_months {
|
||||||
|
print_twelve_months(&ctx, year, month);
|
||||||
|
} else if args.three_months {
|
||||||
|
print_three_months(&ctx, year, month);
|
||||||
|
} else if let Some(count) = args.months_count {
|
||||||
|
print_months_count(&ctx, year, month, count)?;
|
||||||
|
} else {
|
||||||
|
print_month(&ctx, year, month);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
176
src/plugin_api.rs
Normal file
176
src/plugin_api.rs
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
//! Plugin API for dynamic loading of holiday highlighter.
|
||||||
|
|
||||||
|
use libc::{c_char, c_int};
|
||||||
|
use std::ffi::{CStr, CString};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Handle to a loaded plugin.
|
||||||
|
pub struct PluginHandle {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
lib: libloading::Library,
|
||||||
|
get_holidays_fn: libloading::Symbol<
|
||||||
|
'static,
|
||||||
|
unsafe extern "C" fn(c_int, c_int, *const c_char) -> *mut c_char,
|
||||||
|
>,
|
||||||
|
free_holidays_fn: libloading::Symbol<'static, unsafe extern "C" fn(*mut c_char)>,
|
||||||
|
get_country_fn: libloading::Symbol<'static, unsafe extern "C" fn() -> *mut c_char>,
|
||||||
|
free_country_fn: libloading::Symbol<'static, unsafe extern "C" fn(*mut c_char)>,
|
||||||
|
is_holiday_fn: libloading::Symbol<
|
||||||
|
'static,
|
||||||
|
unsafe extern "C" fn(c_int, c_int, c_int, *const c_char) -> c_int,
|
||||||
|
>,
|
||||||
|
get_year_holidays_fn:
|
||||||
|
libloading::Symbol<'static, unsafe extern "C" fn(c_int, *const c_char) -> *mut c_char>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginHandle {
|
||||||
|
/// Load plugin from path.
|
||||||
|
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, libloading::Error> {
|
||||||
|
let lib = unsafe { libloading::Library::new(path.as_ref())? };
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let get_holidays_fn: libloading::Symbol<
|
||||||
|
unsafe extern "C" fn(c_int, c_int, *const c_char) -> *mut c_char,
|
||||||
|
> = lib.get(b"plugin_get_holidays")?;
|
||||||
|
|
||||||
|
let free_holidays_fn: libloading::Symbol<unsafe extern "C" fn(*mut c_char)> =
|
||||||
|
lib.get(b"plugin_free_holidays")?;
|
||||||
|
|
||||||
|
let get_country_fn: libloading::Symbol<unsafe extern "C" fn() -> *mut c_char> =
|
||||||
|
lib.get(b"plugin_get_country_from_locale")?;
|
||||||
|
|
||||||
|
let free_country_fn: libloading::Symbol<unsafe extern "C" fn(*mut c_char)> =
|
||||||
|
lib.get(b"plugin_free_country")?;
|
||||||
|
|
||||||
|
let is_holiday_fn: libloading::Symbol<
|
||||||
|
unsafe extern "C" fn(c_int, c_int, c_int, *const c_char) -> c_int,
|
||||||
|
> = lib.get(b"plugin_is_holiday")?;
|
||||||
|
|
||||||
|
let get_year_holidays_fn: libloading::Symbol<
|
||||||
|
unsafe extern "C" fn(c_int, *const c_char) -> *mut c_char,
|
||||||
|
> = lib.get(b"plugin_get_year_holidays")?;
|
||||||
|
|
||||||
|
// Extend lifetime to match struct
|
||||||
|
let get_holidays_fn: libloading::Symbol<
|
||||||
|
'static,
|
||||||
|
unsafe extern "C" fn(c_int, c_int, *const c_char) -> *mut c_char,
|
||||||
|
> = std::mem::transmute(get_holidays_fn);
|
||||||
|
let free_holidays_fn: libloading::Symbol<'static, unsafe extern "C" fn(*mut c_char)> =
|
||||||
|
std::mem::transmute(free_holidays_fn);
|
||||||
|
let get_country_fn: libloading::Symbol<'static, unsafe extern "C" fn() -> *mut c_char> =
|
||||||
|
std::mem::transmute(get_country_fn);
|
||||||
|
let free_country_fn: libloading::Symbol<'static, unsafe extern "C" fn(*mut c_char)> =
|
||||||
|
std::mem::transmute(free_country_fn);
|
||||||
|
let is_holiday_fn: libloading::Symbol<
|
||||||
|
'static,
|
||||||
|
unsafe extern "C" fn(c_int, c_int, c_int, *const c_char) -> c_int,
|
||||||
|
> = std::mem::transmute(is_holiday_fn);
|
||||||
|
let get_year_holidays_fn: libloading::Symbol<
|
||||||
|
'static,
|
||||||
|
unsafe extern "C" fn(c_int, *const c_char) -> *mut c_char,
|
||||||
|
> = std::mem::transmute(get_year_holidays_fn);
|
||||||
|
|
||||||
|
Ok(PluginHandle {
|
||||||
|
lib,
|
||||||
|
get_holidays_fn,
|
||||||
|
free_holidays_fn,
|
||||||
|
get_country_fn,
|
||||||
|
free_country_fn,
|
||||||
|
is_holiday_fn,
|
||||||
|
get_year_holidays_fn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get holiday data for a specific month.
|
||||||
|
///
|
||||||
|
/// Returns string where each character represents a day:
|
||||||
|
/// - '0' = working day, '1' = weekend, '2' = shortened, '8' = public holiday
|
||||||
|
pub fn get_holidays(&self, year: i32, month: u32, country: &str) -> Option<String> {
|
||||||
|
let country_cstr = CString::new(country).ok()?;
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let result =
|
||||||
|
(self.get_holidays_fn)(year as c_int, month as c_int, country_cstr.as_ptr());
|
||||||
|
if result.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rust_str = CStr::from_ptr(result).to_str().ok()?.to_string();
|
||||||
|
(self.free_holidays_fn)(result);
|
||||||
|
Some(rust_str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get holiday data for entire year.
|
||||||
|
pub fn get_year_holidays(&self, year: i32, country: &str) -> Option<String> {
|
||||||
|
let country_cstr = CString::new(country).ok()?;
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let result = (self.get_year_holidays_fn)(year as c_int, country_cstr.as_ptr());
|
||||||
|
if result.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rust_str = CStr::from_ptr(result).to_str().ok()?.to_string();
|
||||||
|
(self.free_holidays_fn)(result);
|
||||||
|
Some(rust_str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get country code from system locale.
|
||||||
|
pub fn get_country_from_locale(&self) -> String {
|
||||||
|
unsafe {
|
||||||
|
let result = (self.get_country_fn)();
|
||||||
|
if result.is_null() {
|
||||||
|
return "RU".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let country = CStr::from_ptr(result).to_str().unwrap_or("RU").to_string();
|
||||||
|
(self.free_country_fn)(result);
|
||||||
|
country
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a specific day is a holiday.
|
||||||
|
///
|
||||||
|
/// Returns: 0=working, 1=weekend, 2=shortened, 8=public, -1=error
|
||||||
|
pub fn is_holiday(&self, year: i32, month: u32, day: u32, country: &str) -> i32 {
|
||||||
|
let country_cstr = CString::new(country).unwrap();
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
(self.is_holiday_fn)(
|
||||||
|
year as c_int,
|
||||||
|
month as c_int,
|
||||||
|
day as c_int,
|
||||||
|
country_cstr.as_ptr(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to load the holiday plugin from standard locations.
|
||||||
|
pub fn try_load_plugin() -> Option<PluginHandle> {
|
||||||
|
let search_paths = [
|
||||||
|
// Build directory (development)
|
||||||
|
"./target/debug/libholiday_highlighter.so",
|
||||||
|
"./target/release/libholiday_highlighter.so",
|
||||||
|
// User local directory
|
||||||
|
"~/.local/lib/cal/plugins/libholiday_highlighter.so",
|
||||||
|
// System directory
|
||||||
|
"/usr/lib/cal/plugins/libholiday_highlighter.so",
|
||||||
|
"/usr/local/lib/cal/plugins/libholiday_highlighter.so",
|
||||||
|
// Relative to executable
|
||||||
|
"./plugins/libholiday_highlighter.so",
|
||||||
|
"./libholiday_highlighter.so",
|
||||||
|
];
|
||||||
|
|
||||||
|
for path in &search_paths {
|
||||||
|
let expanded = shellexpand::tilde(path);
|
||||||
|
if let Ok(handle) = PluginHandle::load(expanded.as_ref()) {
|
||||||
|
return Some(handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
110
src/types.rs
Normal file
110
src/types.rs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
//! Type definitions and constants for calendar formatting.
|
||||||
|
|
||||||
|
use chrono::Weekday;
|
||||||
|
use clap::ValueEnum;
|
||||||
|
|
||||||
|
/// Calendar reform type determining which calendar system to use.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, ValueEnum)]
|
||||||
|
#[value(rename_all = "lowercase")]
|
||||||
|
pub enum ReformType {
|
||||||
|
/// Gregorian calendar (always).
|
||||||
|
Gregorian,
|
||||||
|
/// ISO 8601 calendar (same as Gregorian).
|
||||||
|
Iso,
|
||||||
|
/// Julian calendar (always).
|
||||||
|
Julian,
|
||||||
|
/// Great Britain reform year 1752 (September 3-13 were skipped).
|
||||||
|
#[value(name = "1752")]
|
||||||
|
Year1752,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReformType {
|
||||||
|
/// Return the reform year for this calendar type.
|
||||||
|
pub fn reform_year(self) -> i32 {
|
||||||
|
match self {
|
||||||
|
ReformType::Gregorian | ReformType::Iso => i32::MIN,
|
||||||
|
ReformType::Julian => i32::MAX,
|
||||||
|
ReformType::Year1752 => REFORM_YEAR_GB,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Week numbering system for calendar display.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, ValueEnum)]
|
||||||
|
pub enum WeekType {
|
||||||
|
/// ISO 8601: week starts on Monday, week 1 contains the first Thursday.
|
||||||
|
Iso,
|
||||||
|
/// US style: week starts on Sunday, week 1 contains January 1.
|
||||||
|
Us,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Column display mode for multi-month layouts.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum ColumnsMode {
|
||||||
|
/// Fixed number of columns.
|
||||||
|
Fixed(u32),
|
||||||
|
/// Auto-detect from terminal width.
|
||||||
|
Auto,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calendar formatting context containing all display options.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct CalContext {
|
||||||
|
/// Year when calendar reform occurred (i32::MIN = always Gregorian, i32::MAX = always Julian).
|
||||||
|
pub reform_year: i32,
|
||||||
|
/// First day of the week (Monday or Sunday).
|
||||||
|
pub week_start: Weekday,
|
||||||
|
/// Whether to display Julian day numbers (day of year).
|
||||||
|
pub julian: bool,
|
||||||
|
/// Whether to display ISO week numbers.
|
||||||
|
pub week_numbers: bool,
|
||||||
|
/// Week numbering system (ISO or US).
|
||||||
|
pub week_type: WeekType,
|
||||||
|
/// Whether to use ANSI color codes in output.
|
||||||
|
pub color: bool,
|
||||||
|
/// Whether to display days vertically (days in columns instead of rows).
|
||||||
|
pub vertical: bool,
|
||||||
|
/// Today's date for highlighting.
|
||||||
|
pub today: chrono::NaiveDate,
|
||||||
|
/// Whether to show year in month headers.
|
||||||
|
pub show_year_in_header: bool,
|
||||||
|
/// Width of gutter between months in multi-month display.
|
||||||
|
pub gutter_width: usize,
|
||||||
|
/// Column display mode.
|
||||||
|
pub columns: ColumnsMode,
|
||||||
|
/// Whether to center the date range when displaying multiple months.
|
||||||
|
pub span: bool,
|
||||||
|
/// Whether to highlight holidays using isdayoff.ru API.
|
||||||
|
#[cfg(feature = "plugins")]
|
||||||
|
pub holidays: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calendar data for a single month.
|
||||||
|
pub struct MonthData {
|
||||||
|
pub year: i32,
|
||||||
|
pub month: u32,
|
||||||
|
pub days: Vec<Option<u32>>,
|
||||||
|
pub week_numbers: Vec<Option<u32>>,
|
||||||
|
pub weekdays: Vec<Option<Weekday>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constants for calendar formatting
|
||||||
|
pub const CELLS_PER_MONTH: usize = 42; // 6 weeks × 7 days
|
||||||
|
pub const GUTTER_WIDTH_REGULAR: usize = 2;
|
||||||
|
pub const GUTTER_WIDTH_YEAR: usize = 3;
|
||||||
|
|
||||||
|
// Color is enabled by default for better user experience
|
||||||
|
pub const COLOR_ENABLED_BY_DEFAULT: bool = true;
|
||||||
|
|
||||||
|
// Reform year for September 1752 (missing days 3-13 in Great Britain)
|
||||||
|
pub const REFORM_YEAR_GB: i32 = 1752;
|
||||||
|
pub const REFORM_MONTH: u32 = 9;
|
||||||
|
pub const REFORM_FIRST_DAY: u32 = 3;
|
||||||
|
pub const REFORM_LAST_DAY: u32 = 13;
|
||||||
|
|
||||||
|
// ANSI color codes
|
||||||
|
pub const COLOR_RESET: &str = "\x1b[0m";
|
||||||
|
pub const COLOR_REVERSE: &str = "\x1b[7m";
|
||||||
|
pub const COLOR_RED: &str = "\x1b[91m";
|
||||||
|
pub const COLOR_TEAL: &str = "\x1b[96m";
|
||||||
|
pub const COLOR_SAND_YELLOW: &str = "\x1b[93m";
|
||||||
700
tests/integration_tests.rs
Normal file
700
tests/integration_tests.rs
Normal file
@@ -0,0 +1,700 @@
|
|||||||
|
//! Integration tests for calendar calculation logic.
|
||||||
|
|
||||||
|
use chrono::Weekday;
|
||||||
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
|
use cal::formatter::parse_month;
|
||||||
|
use cal::types::{CalContext, ColumnsMode, MonthData, ReformType, WeekType};
|
||||||
|
|
||||||
|
fn test_context() -> CalContext {
|
||||||
|
CalContext {
|
||||||
|
reform_year: ReformType::Year1752.reform_year(),
|
||||||
|
week_start: Weekday::Mon,
|
||||||
|
julian: false,
|
||||||
|
week_numbers: false,
|
||||||
|
week_type: WeekType::Iso,
|
||||||
|
color: false,
|
||||||
|
vertical: false,
|
||||||
|
today: chrono::NaiveDate::from_ymd_opt(2026, 2, 18).unwrap(),
|
||||||
|
show_year_in_header: true,
|
||||||
|
gutter_width: 2,
|
||||||
|
columns: ColumnsMode::Auto,
|
||||||
|
span: false,
|
||||||
|
#[cfg(feature = "plugins")]
|
||||||
|
holidays: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn julian_context() -> CalContext {
|
||||||
|
CalContext {
|
||||||
|
reform_year: ReformType::Julian.reform_year(),
|
||||||
|
..test_context()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gregorian_context() -> CalContext {
|
||||||
|
CalContext {
|
||||||
|
reform_year: ReformType::Gregorian.reform_year(),
|
||||||
|
..test_context()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod leap_year_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_gregorian_leap_year_divisible_by_400() {
|
||||||
|
let ctx = gregorian_context();
|
||||||
|
assert!(ctx.is_leap_year(2000));
|
||||||
|
assert!(ctx.is_leap_year(2400));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_gregorian_leap_year_divisible_by_4_not_100() {
|
||||||
|
let ctx = gregorian_context();
|
||||||
|
assert!(ctx.is_leap_year(2024));
|
||||||
|
assert!(ctx.is_leap_year(2028));
|
||||||
|
assert!(!ctx.is_leap_year(2023));
|
||||||
|
assert!(!ctx.is_leap_year(2025));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_gregorian_not_leap_year_divisible_by_100() {
|
||||||
|
let ctx = gregorian_context();
|
||||||
|
assert!(!ctx.is_leap_year(1900));
|
||||||
|
assert!(!ctx.is_leap_year(2100));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_julian_leap_year() {
|
||||||
|
let ctx = julian_context();
|
||||||
|
assert!(ctx.is_leap_year(2024));
|
||||||
|
assert!(ctx.is_leap_year(2028));
|
||||||
|
assert!(ctx.is_leap_year(1900));
|
||||||
|
assert!(!ctx.is_leap_year(2023));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_year_1752_leap_year() {
|
||||||
|
let ctx = test_context();
|
||||||
|
assert!(ctx.is_leap_year(1752));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod days_in_month_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_31_day_months() {
|
||||||
|
let ctx = test_context();
|
||||||
|
for month in [1, 3, 5, 7, 8, 10, 12] {
|
||||||
|
assert_eq!(ctx.days_in_month(2024, month), 31);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_30_day_months() {
|
||||||
|
let ctx = test_context();
|
||||||
|
for month in [4, 6, 9, 11] {
|
||||||
|
assert_eq!(ctx.days_in_month(2024, month), 30);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_february_leap_year() {
|
||||||
|
let ctx = test_context();
|
||||||
|
assert_eq!(ctx.days_in_month(2024, 2), 29);
|
||||||
|
assert_eq!(ctx.days_in_month(2028, 2), 29);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_february_non_leap_year() {
|
||||||
|
let ctx = test_context();
|
||||||
|
assert_eq!(ctx.days_in_month(2023, 2), 28);
|
||||||
|
assert_eq!(ctx.days_in_month(2025, 2), 28);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod first_day_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_first_day_known_dates() {
|
||||||
|
let ctx = test_context();
|
||||||
|
|
||||||
|
assert_eq!(ctx.first_day_of_month(2024, 1), Weekday::Mon);
|
||||||
|
assert_eq!(ctx.first_day_of_month(2025, 1), Weekday::Wed);
|
||||||
|
assert_eq!(ctx.first_day_of_month(2024, 2), Weekday::Thu);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_first_day_september_1752() {
|
||||||
|
let ctx = test_context();
|
||||||
|
assert_eq!(ctx.first_day_of_month(1752, 9), Weekday::Fri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod reform_gap_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reform_gap_detection() {
|
||||||
|
let ctx = test_context();
|
||||||
|
|
||||||
|
assert!(ctx.is_reform_gap(1752, 9, 3));
|
||||||
|
assert!(ctx.is_reform_gap(1752, 9, 13));
|
||||||
|
assert!(ctx.is_reform_gap(1752, 9, 8));
|
||||||
|
|
||||||
|
assert!(!ctx.is_reform_gap(1752, 9, 2));
|
||||||
|
assert!(!ctx.is_reform_gap(1752, 9, 14));
|
||||||
|
|
||||||
|
assert!(!ctx.is_reform_gap(1752, 8, 5));
|
||||||
|
assert!(!ctx.is_reform_gap(1752, 10, 5));
|
||||||
|
assert!(!ctx.is_reform_gap(1751, 9, 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_reform_gap_gregorian() {
|
||||||
|
let ctx = gregorian_context();
|
||||||
|
assert!(!ctx.is_reform_gap(1752, 9, 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_reform_gap_julian() {
|
||||||
|
let ctx = julian_context();
|
||||||
|
assert!(!ctx.is_reform_gap(1752, 9, 5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod day_of_year_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_day_of_year_non_leap() {
|
||||||
|
let ctx = test_context();
|
||||||
|
|
||||||
|
assert_eq!(ctx.day_of_year(2023, 1, 1), 1);
|
||||||
|
assert_eq!(ctx.day_of_year(2023, 1, 31), 31);
|
||||||
|
assert_eq!(ctx.day_of_year(2023, 2, 1), 32);
|
||||||
|
assert_eq!(ctx.day_of_year(2023, 12, 31), 365);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_day_of_year_leap() {
|
||||||
|
let ctx = test_context();
|
||||||
|
|
||||||
|
assert_eq!(ctx.day_of_year(2024, 1, 1), 1);
|
||||||
|
assert_eq!(ctx.day_of_year(2024, 2, 29), 60);
|
||||||
|
assert_eq!(ctx.day_of_year(2024, 3, 1), 61);
|
||||||
|
assert_eq!(ctx.day_of_year(2024, 12, 31), 366);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_day_of_year_reform_gap() {
|
||||||
|
let ctx = test_context();
|
||||||
|
|
||||||
|
assert_eq!(ctx.day_of_year(1752, 9, 2), 235);
|
||||||
|
assert_eq!(ctx.day_of_year(1752, 9, 14), 247);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod month_data_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_month_data_january_2024() {
|
||||||
|
let ctx = test_context();
|
||||||
|
let month = MonthData::new(&ctx, 2024, 1);
|
||||||
|
|
||||||
|
assert_eq!(month.year, 2024);
|
||||||
|
assert_eq!(month.month, 1);
|
||||||
|
assert_eq!(month.days.len(), 42);
|
||||||
|
|
||||||
|
// January 2024 starts on Monday
|
||||||
|
assert_eq!(month.days[0], Some(1));
|
||||||
|
assert_eq!(month.days[30], Some(31));
|
||||||
|
assert_eq!(month.days[31], None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_month_data_february_2024_leap() {
|
||||||
|
let ctx = test_context();
|
||||||
|
let month = MonthData::new(&ctx, 2024, 2);
|
||||||
|
|
||||||
|
// February 2024 starts on Thursday
|
||||||
|
assert_eq!(month.days[0], None);
|
||||||
|
assert_eq!(month.days[1], None);
|
||||||
|
assert_eq!(month.days[2], None);
|
||||||
|
assert_eq!(month.days[3], Some(1));
|
||||||
|
assert_eq!(month.days[31], Some(29));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_month_data_september_1752_reform() {
|
||||||
|
let ctx = test_context();
|
||||||
|
let month = MonthData::new(&ctx, 1752, 9);
|
||||||
|
|
||||||
|
assert!(month.days.contains(&Some(2)));
|
||||||
|
assert!(!month.days.contains(&Some(3)));
|
||||||
|
assert!(!month.days.contains(&Some(13)));
|
||||||
|
assert!(month.days.contains(&Some(14)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_month_data_weekday_alignment() {
|
||||||
|
let ctx = test_context();
|
||||||
|
|
||||||
|
for month in 1..=12 {
|
||||||
|
let month_data = MonthData::new(&ctx, 2024, month);
|
||||||
|
|
||||||
|
for (i, day) in month_data.days.iter().enumerate() {
|
||||||
|
if day.is_some() {
|
||||||
|
assert!(month_data.weekdays[i].is_some());
|
||||||
|
} else {
|
||||||
|
assert!(month_data.weekdays[i].is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod weekend_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_weekend() {
|
||||||
|
let ctx = test_context();
|
||||||
|
|
||||||
|
assert!(ctx.is_weekend(Weekday::Sat));
|
||||||
|
assert!(ctx.is_weekend(Weekday::Sun));
|
||||||
|
assert!(!ctx.is_weekend(Weekday::Mon));
|
||||||
|
assert!(!ctx.is_weekend(Weekday::Tue));
|
||||||
|
assert!(!ctx.is_weekend(Weekday::Wed));
|
||||||
|
assert!(!ctx.is_weekend(Weekday::Thu));
|
||||||
|
assert!(!ctx.is_weekend(Weekday::Fri));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod week_number_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_iso_week_number() {
|
||||||
|
let mut ctx = test_context();
|
||||||
|
ctx.week_type = WeekType::Iso;
|
||||||
|
|
||||||
|
assert_eq!(ctx.week_number(2024, 1, 1), 1);
|
||||||
|
|
||||||
|
let week = ctx.week_number(2024, 12, 30);
|
||||||
|
assert!(week == 1 || week == 53);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_us_week_number() {
|
||||||
|
let mut ctx = test_context();
|
||||||
|
ctx.week_type = WeekType::Us;
|
||||||
|
|
||||||
|
assert_eq!(ctx.week_number(2024, 1, 1), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod context_validation_tests {
|
||||||
|
use super::*;
|
||||||
|
use cal::args::Args;
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_creation_default() {
|
||||||
|
let args = Args::parse_from(["cal"]);
|
||||||
|
let ctx = CalContext::new(&args);
|
||||||
|
assert!(ctx.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_creation_with_options() {
|
||||||
|
let args = Args::parse_from(["cal", "-y", "-j", "-w"]);
|
||||||
|
let ctx = CalContext::new(&args);
|
||||||
|
assert!(ctx.is_ok());
|
||||||
|
let ctx = ctx.unwrap();
|
||||||
|
assert!(ctx.julian);
|
||||||
|
assert!(ctx.week_numbers);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_mutually_exclusive_options() {
|
||||||
|
let args = Args::parse_from(["cal", "-y", "-n", "5"]);
|
||||||
|
let result = CalContext::new(&args);
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert!(err.contains("mutually exclusive"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_invalid_columns() {
|
||||||
|
let args = Args::parse_from(["cal", "-c", "0"]);
|
||||||
|
assert!(CalContext::new(&args).is_err());
|
||||||
|
|
||||||
|
let args = Args::parse_from(["cal", "-c", "abc"]);
|
||||||
|
assert!(CalContext::new(&args).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_sunday_start() {
|
||||||
|
let args = Args::parse_from(["cal", "-s"]);
|
||||||
|
let ctx = CalContext::new(&args).unwrap();
|
||||||
|
assert_eq!(ctx.week_start, Weekday::Sun);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_color_settings() {
|
||||||
|
let args = Args::parse_from(["cal"]);
|
||||||
|
let ctx = CalContext::new(&args).unwrap();
|
||||||
|
assert!(!ctx.color);
|
||||||
|
|
||||||
|
let args = Args::parse_from(["cal", "--color"]);
|
||||||
|
let ctx = CalContext::new(&args).unwrap();
|
||||||
|
assert!(!ctx.color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context_reform_types() {
|
||||||
|
let args = Args::parse_from(["cal", "--reform", "gregorian"]);
|
||||||
|
let ctx = CalContext::new(&args).unwrap();
|
||||||
|
assert_eq!(ctx.reform_year, i32::MIN);
|
||||||
|
|
||||||
|
let args = Args::parse_from(["cal", "--reform", "julian"]);
|
||||||
|
let ctx = CalContext::new(&args).unwrap();
|
||||||
|
assert_eq!(ctx.reform_year, i32::MAX);
|
||||||
|
|
||||||
|
let args = Args::parse_from(["cal", "--iso"]);
|
||||||
|
let ctx = CalContext::new(&args).unwrap();
|
||||||
|
assert_eq!(ctx.reform_year, i32::MIN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod parse_month_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_month_numeric() {
|
||||||
|
for (input, expected) in [
|
||||||
|
("1", Some(1)),
|
||||||
|
("2", Some(2)),
|
||||||
|
("12", Some(12)),
|
||||||
|
("0", None),
|
||||||
|
("13", None),
|
||||||
|
("abc", None),
|
||||||
|
] {
|
||||||
|
assert_eq!(parse_month(input), expected, "Failed for input: {}", input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_month_english_names() {
|
||||||
|
assert_eq!(parse_month("january"), Some(1));
|
||||||
|
assert_eq!(parse_month("January"), Some(1));
|
||||||
|
assert_eq!(parse_month("JANUARY"), Some(1));
|
||||||
|
assert_eq!(parse_month("february"), Some(2));
|
||||||
|
assert_eq!(parse_month("december"), Some(12));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_month_english_short() {
|
||||||
|
assert_eq!(parse_month("jan"), Some(1));
|
||||||
|
assert_eq!(parse_month("feb"), Some(2));
|
||||||
|
assert_eq!(parse_month("mar"), Some(3));
|
||||||
|
assert_eq!(parse_month("apr"), Some(4));
|
||||||
|
assert_eq!(parse_month("jun"), Some(6));
|
||||||
|
assert_eq!(parse_month("jul"), Some(7));
|
||||||
|
assert_eq!(parse_month("aug"), Some(8));
|
||||||
|
assert_eq!(parse_month("sep"), Some(9));
|
||||||
|
assert_eq!(parse_month("oct"), Some(10));
|
||||||
|
assert_eq!(parse_month("nov"), Some(11));
|
||||||
|
assert_eq!(parse_month("dec"), Some(12));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_month_russian() {
|
||||||
|
assert_eq!(parse_month("январь"), Some(1));
|
||||||
|
assert_eq!(parse_month("февраль"), Some(2));
|
||||||
|
assert_eq!(parse_month("декабрь"), Some(12));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod layout_tests {
|
||||||
|
use super::*;
|
||||||
|
use cal::formatter::{
|
||||||
|
format_month_grid, format_month_header, format_weekday_headers, get_weekday_order,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_month_header_format() {
|
||||||
|
let header = format_month_header(2026, 2, 20, true, false);
|
||||||
|
assert!(header.contains("Февраль"));
|
||||||
|
assert!(header.contains("2026"));
|
||||||
|
assert!(header.width() >= 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_month_header_without_year() {
|
||||||
|
let header = format_month_header(2026, 2, 20, false, false);
|
||||||
|
assert!(header.contains("Февраль"));
|
||||||
|
assert!(!header.contains("2026"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_month_header_with_color() {
|
||||||
|
let header = format_month_header(2026, 2, 20, true, true);
|
||||||
|
assert!(header.contains("\x1b[96m"));
|
||||||
|
assert!(header.contains("\x1b[0m"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_weekday_header_structure_monday_start() {
|
||||||
|
let ctx = test_context();
|
||||||
|
let header = format_weekday_headers(&ctx, false);
|
||||||
|
|
||||||
|
assert!(header.contains("Пн"));
|
||||||
|
assert!(header.contains("Вт"));
|
||||||
|
assert!(header.contains("Ср"));
|
||||||
|
assert!(header.contains("Чт"));
|
||||||
|
assert!(header.contains("Пт"));
|
||||||
|
assert!(header.contains("Сб"));
|
||||||
|
assert!(header.contains("Вс"));
|
||||||
|
|
||||||
|
let mon_pos = header.find("Пн").unwrap();
|
||||||
|
let tue_pos = header.find("Вт").unwrap();
|
||||||
|
assert!(mon_pos < tue_pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_weekday_header_structure_sunday_start() {
|
||||||
|
let mut ctx = test_context();
|
||||||
|
ctx.week_start = chrono::Weekday::Sun;
|
||||||
|
let header = format_weekday_headers(&ctx, false);
|
||||||
|
|
||||||
|
let sun_pos = header.find("Вс").unwrap();
|
||||||
|
let mon_pos = header.find("Пн").unwrap();
|
||||||
|
assert!(sun_pos < mon_pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_weekday_header_with_week_numbers() {
|
||||||
|
let mut ctx = test_context();
|
||||||
|
ctx.week_numbers = true;
|
||||||
|
let header = format_weekday_headers(&ctx, false);
|
||||||
|
assert!(header.len() > 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_weekday_header_with_julian() {
|
||||||
|
let mut ctx = test_context();
|
||||||
|
ctx.julian = true;
|
||||||
|
let header = format_weekday_headers(&ctx, false);
|
||||||
|
assert!(header.starts_with(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_month_grid_line_count() {
|
||||||
|
let ctx = test_context();
|
||||||
|
let month = MonthData::new(&ctx, 2024, 1);
|
||||||
|
let grid = format_month_grid(&ctx, &month);
|
||||||
|
|
||||||
|
assert!(grid.len() >= 8);
|
||||||
|
assert!(grid.len() <= 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_month_grid_first_line_is_header() {
|
||||||
|
let ctx = test_context();
|
||||||
|
let month = MonthData::new(&ctx, 2024, 1);
|
||||||
|
let grid = format_month_grid(&ctx, &month);
|
||||||
|
|
||||||
|
assert!(grid[0].contains("Январь"));
|
||||||
|
assert!(grid[0].contains("2024"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_month_grid_second_line_is_weekdays() {
|
||||||
|
let ctx = test_context();
|
||||||
|
let month = MonthData::new(&ctx, 2024, 1);
|
||||||
|
let grid = format_month_grid(&ctx, &month);
|
||||||
|
|
||||||
|
assert!(grid[1].contains("Пн"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_month_grid_contains_day_1() {
|
||||||
|
let ctx = test_context();
|
||||||
|
let month = MonthData::new(&ctx, 2024, 1);
|
||||||
|
let grid = format_month_grid(&ctx, &month);
|
||||||
|
|
||||||
|
let days_area: String = grid[2..].join("\n");
|
||||||
|
assert!(days_area.contains(" 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_month_grid_contains_last_day() {
|
||||||
|
let ctx = test_context();
|
||||||
|
let month = MonthData::new(&ctx, 2024, 1);
|
||||||
|
let grid = format_month_grid(&ctx, &month);
|
||||||
|
|
||||||
|
let days_area: String = grid[2..].join("\n");
|
||||||
|
assert!(days_area.contains("31"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_month_grid_february_leap_year() {
|
||||||
|
let ctx = test_context();
|
||||||
|
let month = MonthData::new(&ctx, 2024, 2);
|
||||||
|
let grid = format_month_grid(&ctx, &month);
|
||||||
|
|
||||||
|
let days_area: String = grid[2..].join("\n");
|
||||||
|
assert!(days_area.contains("29"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_month_grid_february_non_leap_year() {
|
||||||
|
let ctx = test_context();
|
||||||
|
let month = MonthData::new(&ctx, 2023, 2);
|
||||||
|
let grid = format_month_grid(&ctx, &month);
|
||||||
|
|
||||||
|
let days_area: String = grid[2..].join("\n");
|
||||||
|
assert!(days_area.contains("28"));
|
||||||
|
assert!(!days_area.contains("29"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_vertical_layout_weekday_order() {
|
||||||
|
let ctx = test_context();
|
||||||
|
let weekday_order = get_weekday_order(ctx.week_start);
|
||||||
|
|
||||||
|
assert_eq!(weekday_order[0], chrono::Weekday::Mon);
|
||||||
|
assert_eq!(weekday_order[6], chrono::Weekday::Sun);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_vertical_layout_days_in_columns() {
|
||||||
|
let ctx = test_context();
|
||||||
|
let month = MonthData::new(&ctx, 2024, 1);
|
||||||
|
|
||||||
|
let day1_pos = month.days.iter().position(|&d| d == Some(1)).unwrap();
|
||||||
|
let day8_pos = month.days.iter().position(|&d| d == Some(8)).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(day8_pos - day1_pos, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_three_months_structure() {
|
||||||
|
let ctx = test_context();
|
||||||
|
let prev = MonthData::new(&ctx, 2024, 1);
|
||||||
|
let curr = MonthData::new(&ctx, 2024, 2);
|
||||||
|
let next = MonthData::new(&ctx, 2024, 3);
|
||||||
|
|
||||||
|
assert_eq!(prev.month, 1);
|
||||||
|
assert_eq!(curr.month, 2);
|
||||||
|
assert_eq!(next.month, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_three_months_year_boundary() {
|
||||||
|
let ctx = test_context();
|
||||||
|
|
||||||
|
let prev = MonthData::new(&ctx, 2023, 12);
|
||||||
|
let curr = MonthData::new(&ctx, 2024, 1);
|
||||||
|
let next = MonthData::new(&ctx, 2024, 2);
|
||||||
|
|
||||||
|
assert_eq!(prev.year, 2023);
|
||||||
|
assert_eq!(curr.year, 2024);
|
||||||
|
assert_eq!(next.year, 2024);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_color_codes_in_header() {
|
||||||
|
let header = format_month_header(2024, 1, 20, true, true);
|
||||||
|
assert!(header.starts_with("\x1b[96m"));
|
||||||
|
assert!(header.ends_with("\x1b[0m"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_color_codes_when_disabled() {
|
||||||
|
let header = format_month_header(2024, 1, 20, true, false);
|
||||||
|
assert!(!header.contains("\x1b[96m"));
|
||||||
|
assert!(!header.contains("\x1b[0m"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_weekday_header_color_placement() {
|
||||||
|
let mut ctx = test_context();
|
||||||
|
ctx.color = true;
|
||||||
|
let header = format_weekday_headers(&ctx, false);
|
||||||
|
|
||||||
|
assert!(header.starts_with("\x1b[93m"));
|
||||||
|
assert!(header.ends_with("\x1b[0m"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_weekday_header_no_color_when_disabled() {
|
||||||
|
let mut ctx = test_context();
|
||||||
|
ctx.color = false;
|
||||||
|
let header = format_weekday_headers(&ctx, false);
|
||||||
|
|
||||||
|
assert!(!header.contains("\x1b[93m"));
|
||||||
|
assert!(!header.contains("\x1b[0m"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_header_width_consistency() {
|
||||||
|
let width = 20;
|
||||||
|
let header1 = format_month_header(2024, 1, width, true, false);
|
||||||
|
let header2 = format_month_header(2024, 12, width, true, false);
|
||||||
|
|
||||||
|
assert_eq!(header1.width(), width);
|
||||||
|
assert_eq!(header2.width(), width);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_day_alignment_in_grid() {
|
||||||
|
let ctx = test_context();
|
||||||
|
let month = MonthData::new(&ctx, 2024, 1);
|
||||||
|
let grid = format_month_grid(&ctx, &month);
|
||||||
|
|
||||||
|
let expected_width = grid[2].width();
|
||||||
|
for (i, line) in grid.iter().enumerate().skip(2) {
|
||||||
|
assert_eq!(
|
||||||
|
line.width(),
|
||||||
|
expected_width,
|
||||||
|
"Line {} has inconsistent width",
|
||||||
|
i
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod get_display_date_tests {
|
||||||
|
use cal::args::{Args, get_display_date};
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_single_year_argument() {
|
||||||
|
let args = Args::parse_from(["cal", "2026"]);
|
||||||
|
let (year, _month, day) = get_display_date(&args).unwrap();
|
||||||
|
assert_eq!(year, 2026);
|
||||||
|
assert_eq!(day, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_single_month_argument() {
|
||||||
|
let args = Args::parse_from(["cal", "2"]);
|
||||||
|
let (_year, month, _day) = get_display_date(&args).unwrap();
|
||||||
|
assert_eq!(month, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_month_year_arguments() {
|
||||||
|
let args = Args::parse_from(["cal", "2", "2026"]);
|
||||||
|
let (year, month, _day) = get_display_date(&args).unwrap();
|
||||||
|
assert_eq!(year, 2026);
|
||||||
|
assert_eq!(month, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user