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}