linkleaf_core/validation.rs
1//! Validation utilities for user-provided arguments (CLI-friendly).
2//!
3//! The parsers here return `Result<_, String>` so they plug directly into
4//! `clap`'s `value_parser` attribute. The error strings are short, user-facing
5//! messages suitable for terminal output.
6
7use anyhow::Result;
8use time::{Date, format_description::FormatItem, macros::format_description};
9
10// A shared, zero-allocation format description for strict `YYYY-MM-DD`.
11const DATE_FMT: &[FormatItem<'_>] = format_description!("[year]-[month]-[day]");
12
13/// Strictly parse a calendar date in `YYYY-MM-DD` format.
14///
15/// ## Behavior
16/// - Trims surrounding whitespace.
17/// - Requires **zero-padded** year-month-day (e.g., `2025-09-02`).
18/// - Rejects datetime strings (e.g., `2025-09-02 12:34:56`) and other formats.
19/// - Validates real calendar dates (e.g., leap years).
20///
21/// ## Arguments
22/// - `s`: The input string (typically from CLI).
23///
24/// ## Returns
25/// - `Ok(Date)` on success.
26/// - `Err(String)` with a short, user-friendly message otherwise (good for CLI).
27///
28/// ## Examples
29/// ```
30/// use linkleaf_core::validation::parse_date;
31/// let d = parse_date("2025-01-03").unwrap();
32/// assert_eq!(d.to_string(), "2025-01-03");
33/// ```
34///
35/// ```
36/// use linkleaf_core::validation::parse_date;
37/// assert!(parse_date("2025/01/03").is_err());
38/// assert!(parse_date("2025-1-3").is_err()); // not zero-padded
39/// ```
40pub fn parse_date(s: &str) -> Result<Date, String> {
41 // Accept strictly "YYYY-MM-DD"
42 Date::parse(s.trim(), DATE_FMT).map_err(|e| e.to_string())
43}
44
45/// Parse a comma-separated tag list into a vector of tags.
46///
47/// ## Behavior
48/// - Splits on commas (`,`).
49/// - Trims whitespace around each tag.
50/// - Drops empty entries (e.g., consecutive commas or trailing commas).
51/// - **Preserves** original case and **preserves order**; no de-duplication.
52/// (Use a normalization step elsewhere if you need lowercase/unique tags.)
53///
54/// ## Arguments
55/// - `raw`: A string like `"rust, async , tokio"`.
56///
57/// ## Returns
58/// - `Ok(Vec<String>)` with the parsed tags (possibly empty).
59/// - `Err(String)` is not used currently; the function is effectively infallible,
60/// but the `Result` type makes it convenient to use with `clap`.
61///
62/// ## Examples
63/// ```
64/// use linkleaf_core::validation::parse_tags;
65/// assert_eq!(parse_tags(" a, b , ,c ").unwrap(), vec!["a","b","c"]);
66/// assert!(parse_tags(" , , ").unwrap().is_empty());
67/// ```
68pub fn parse_tags(raw: &str) -> Result<Vec<String>, String> {
69 let tags = raw
70 .split(',')
71 .map(|t| t.trim())
72 .filter(|t| !t.is_empty())
73 .map(|t| t.to_string())
74 .collect();
75
76 Ok(tags)
77}
78
79#[cfg(test)]
80mod tests {
81 use super::*;
82 use time::Date;
83
84 // ---------- parse_date ----------
85
86 #[test]
87 fn parse_date_accepts_strict_iso() {
88 let d = parse_date("2025-09-02").expect("valid date");
89 assert_eq!(
90 d,
91 Date::from_calendar_date(2025, time::Month::September, 2).unwrap()
92 );
93 }
94
95 #[test]
96 fn parse_date_trims_whitespace() {
97 let d = parse_date(" 2024-02-29 \t").expect("valid leap day with whitespace");
98 assert_eq!(
99 d,
100 Date::from_calendar_date(2024, time::Month::February, 29).unwrap()
101 );
102 }
103
104 #[test]
105 fn parse_date_rejects_datetime() {
106 // Must be exactly YYYY-MM-DD; datetime strings should fail.
107 assert!(parse_date("2025-09-02 12:34:56").is_err());
108 }
109
110 #[test]
111 fn parse_date_rejects_wrong_separator_or_format() {
112 assert!(parse_date("2025/09/02").is_err());
113 assert!(parse_date("02-09-2025").is_err());
114 assert!(parse_date("2025-9-2").is_err()); // no zero-padding → should fail
115 }
116
117 #[test]
118 fn parse_date_rejects_invalid_calendar_dates() {
119 assert!(parse_date("2025-02-30").is_err());
120 assert!(parse_date("2023-02-29").is_err()); // not a leap year
121 assert!(parse_date("2025-13-01").is_err());
122 assert!(parse_date("2025-00-10").is_err());
123 assert!(parse_date("2025-01-00").is_err());
124 }
125
126 // ---------- parse_tags ----------
127
128 #[test]
129 fn parse_tags_empty_string_yields_empty_vec() {
130 let tags = parse_tags("").expect("ok");
131 assert!(tags.is_empty());
132 }
133
134 #[test]
135 fn parse_tags_trims_and_skips_empties() {
136 let tags = parse_tags(" a, b , ,c , , ").expect("ok");
137 assert_eq!(tags, vec!["a", "b", "c"]);
138 }
139
140 #[test]
141 fn parse_tags_single_value() {
142 let tags = parse_tags("rust").expect("ok");
143 assert_eq!(tags, vec!["rust"]);
144 }
145
146 #[test]
147 fn parse_tags_handles_tabs_and_newlines() {
148 let tags = parse_tags("\trust,\n async ,tokio\t").expect("ok");
149 assert_eq!(tags, vec!["rust", "async", "tokio"]);
150 }
151
152 #[test]
153 fn parse_tags_keeps_case_and_order() {
154 let tags = parse_tags("Rust,Async,Tokio").expect("ok");
155 assert_eq!(tags, vec!["Rust", "Async", "Tokio"]);
156 }
157
158 #[test]
159 fn parse_tags_all_commas_or_spaces_is_empty() {
160 let tags = parse_tags(" , , , ").expect("ok");
161 assert!(tags.is_empty());
162 }
163}