Initial commit: Rust calendar utility

This commit is contained in:
2026-02-19 06:57:09 +03:00
commit 56a8b34710
19 changed files with 4755 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

1335
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

29
Cargo.toml Normal file
View 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
View 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
View 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
View 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`)
- Динамические плагины
- Автоматическое определение локали
- Гибкая настройка календарной реформы
- Цветовой вывод с подсветкой сегодня/выходных/праздников

View 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"

View 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 |

View 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` | Резервная локаль для определения страны |

View 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()
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
}
}