Skip to main content

yuki_cli/cli/
documents.rs

1use crate::client::archive::ArchiveClient;
2use crate::config::Config;
3use crate::error::YukiError;
4use crate::folders;
5use crate::output::{ListOptions, OutputFormat, format_json, format_table, is_tty, select_fields};
6
7/// Parse a document type, which Yuki identifies by a numeric ID.
8fn doc_type_id(value: &str) -> Result<i32, YukiError> {
9    value.trim().parse::<i32>().map_err(|_| {
10        YukiError::Config(format!(
11            "invalid document type: {value} (expected a numeric document type ID)"
12        ))
13    })
14}
15
16pub async fn list(
17    config: &Config,
18    _admin: Option<&str>,
19    folder: Option<&str>,
20    doc_type: Option<&str>,
21    format: Option<&str>,
22    opts: ListOptions<'_>,
23) -> Result<(), YukiError> {
24    let mut client = ArchiveClient::new();
25    client.authenticate(&config.api_key).await?;
26
27    // Offset and limit are pushed into the API request, so no client-side slicing.
28    let docs = match (folder, doc_type) {
29        (Some(f), _) => {
30            let folder_id = folders::folder_id(f)?;
31            client
32                .documents_in_folder_paged(
33                    folder_id,
34                    "2000-01-01",
35                    "2099-12-31",
36                    opts.limit,
37                    opts.offset,
38                )
39                .await?
40        }
41        (None, Some(t)) => {
42            let xml = client.documents_by_type(doc_type_id(t)?).await?;
43            // documents_by_type returns raw XML; wrap it for display
44            let headers = vec!["Raw XML".into()];
45            let rows = vec![vec![xml]];
46            let fmt = OutputFormat::from_flag(format, is_tty());
47            match fmt {
48                OutputFormat::Table => println!("{}", format_table(&headers, &rows)),
49                OutputFormat::Json => println!("{}", format_json(&headers, &rows)),
50            }
51            return Ok(());
52        }
53        (None, None) => {
54            client
55                .documents_in_folder_paged(0, "2000-01-01", "2099-12-31", opts.limit, opts.offset)
56                .await?
57        }
58    };
59
60    let mut headers = vec![
61        "ID".into(),
62        "Date".into(),
63        "Amount".into(),
64        "Contact".into(),
65        "Subject".into(),
66        "File".into(),
67    ];
68    let mut rows: Vec<Vec<String>> = docs
69        .into_iter()
70        .map(|d| {
71            vec![
72                d.id,
73                d.document_date,
74                d.amount,
75                d.contact_name,
76                d.subject,
77                d.file_name,
78            ]
79        })
80        .collect();
81    select_fields(&mut headers, &mut rows, &opts)?;
82
83    let fmt = OutputFormat::from_flag(format, is_tty());
84    match fmt {
85        OutputFormat::Table => println!("{}", format_table(&headers, &rows)),
86        OutputFormat::Json => println!("{}", format_json(&headers, &rows)),
87    }
88    Ok(())
89}
90
91pub async fn search(
92    config: &Config,
93    _admin: Option<&str>,
94    query: &str,
95    format: Option<&str>,
96) -> Result<(), YukiError> {
97    let mut client = ArchiveClient::new();
98    client.authenticate(&config.api_key).await?;
99
100    // Use current year as default search range when no date is specified.
101    let year = current_year();
102    let start = format!("{year}-01-01");
103    let end = format!("{year}-12-31");
104
105    let docs = client.search_documents(query, &start, &end).await?;
106
107    let headers = vec![
108        "ID".into(),
109        "Date".into(),
110        "Amount".into(),
111        "Contact".into(),
112        "Subject".into(),
113        "File".into(),
114    ];
115    let rows: Vec<Vec<String>> = docs
116        .into_iter()
117        .map(|d| {
118            vec![
119                d.id,
120                d.document_date,
121                d.amount,
122                d.contact_name,
123                d.subject,
124                d.file_name,
125            ]
126        })
127        .collect();
128
129    let fmt = OutputFormat::from_flag(format, is_tty());
130    match fmt {
131        OutputFormat::Table => println!("{}", format_table(&headers, &rows)),
132        OutputFormat::Json => println!("{}", format_json(&headers, &rows)),
133    }
134    Ok(())
135}
136
137/// Check if an invoice exists in the archive by amount and date (±7 days).
138///
139/// Searches within a month around the given date, then filters by amount (±0.01)
140/// and date proximity (±7 days). Returns matching documents or exit code 3 if none found.
141pub async fn exists(
142    config: &Config,
143    _admin: Option<&str>,
144    amount: f64,
145    date: &str,
146    contact: Option<&str>,
147    format: Option<&str>,
148) -> Result<(), YukiError> {
149    let (search_start, search_end, filter_start, filter_end) = date_range(date)?;
150
151    let mut client = ArchiveClient::new();
152    client.authenticate(&config.api_key).await?;
153
154    let search_text = contact.unwrap_or("");
155    let docs = client
156        .search_documents(search_text, &search_start, &search_end)
157        .await?;
158
159    let matched: Vec<_> = docs
160        .into_iter()
161        .filter(|d| {
162            let amount_matches = d
163                .amount
164                .trim()
165                .parse::<f64>()
166                .map(|a| (a - amount).abs() <= 0.01)
167                .unwrap_or(false);
168            let date_matches = date_in_range(&d.document_date, &filter_start, &filter_end);
169            amount_matches && date_matches
170        })
171        .collect();
172
173    let headers = vec![
174        "ID".into(),
175        "Date".into(),
176        "Amount".into(),
177        "Contact".into(),
178        "Subject".into(),
179        "File".into(),
180    ];
181    let rows: Vec<Vec<String>> = matched
182        .iter()
183        .map(|d| {
184            vec![
185                d.id.clone(),
186                d.document_date.clone(),
187                d.amount.clone(),
188                d.contact_name.clone(),
189                d.subject.clone(),
190                d.file_name.clone(),
191            ]
192        })
193        .collect();
194
195    let fmt = OutputFormat::from_flag(format, is_tty());
196    match fmt {
197        OutputFormat::Table => println!("{}", format_table(&headers, &rows)),
198        OutputFormat::Json => println!("{}", format_json(&headers, &rows)),
199    }
200
201    if rows.is_empty() {
202        return Err(YukiError::NotFound(
203            "no matching document found".to_string(),
204        ));
205    }
206
207    Ok(())
208}
209
210fn current_year() -> u32 {
211    use std::time::{SystemTime, UNIX_EPOCH};
212    let secs = SystemTime::now()
213        .duration_since(UNIX_EPOCH)
214        .unwrap_or_default()
215        .as_secs();
216    1970 + (secs / 31_557_600) as u32
217}
218
219/// Build a search window and date filter from a date string.
220///
221/// Accepts YYYY-MM-DD (±7 day match), YYYY-MM (full month), or YYYY-QN / YYYY (via parse_period).
222/// Returns (search_start, search_end, filter_start, filter_end).
223fn date_range(date: &str) -> Result<(String, String, String, String), YukiError> {
224    let parts: Vec<&str> = date.split('-').collect();
225    match parts.len() {
226        3 => {
227            // YYYY-MM-DD: search ±1 month, filter ±7 days
228            let y: i32 = parts[0].parse().unwrap_or(0);
229            let m: i32 = parts[1].parse().unwrap_or(0);
230            let search_start = format!("{y:04}-{:02}-01", (m - 1).max(1));
231            let search_end = format!("{y:04}-{:02}-28", (m + 1).min(12));
232            let filter_start = shift_days(date, -7);
233            let filter_end = shift_days(date, 7);
234            Ok((search_start, search_end, filter_start, filter_end))
235        }
236        _ => {
237            // YYYY-MM, YYYY-QN, YYYY: use parse_period for both search and filter
238            let (start, end) = crate::period::parse_period(date)?;
239            Ok((start.clone(), end.clone(), start, end))
240        }
241    }
242}
243
244/// Rough date shift by days on a YYYY-MM-DD string.
245fn shift_days(date: &str, days: i32) -> String {
246    let to_days = |s: &str| -> i32 {
247        let p: Vec<&str> = s.split('-').collect();
248        if p.len() < 3 {
249            return 0;
250        }
251        let y: i32 = p[0].parse().unwrap_or(0);
252        let m: i32 = p[1].parse().unwrap_or(0);
253        let d: i32 = p[2].parse().unwrap_or(0);
254        y * 365 + m * 30 + d
255    };
256    let from_days = |total: i32| -> String {
257        let y = total / 365;
258        let rem = total % 365;
259        let m = (rem / 30).clamp(1, 12);
260        let d = (rem % 30).clamp(1, 28);
261        format!("{y:04}-{m:02}-{d:02}")
262    };
263    from_days(to_days(date) + days)
264}
265
266/// Check if a document date falls within a filter range.
267fn date_in_range(doc_date: &str, filter_start: &str, filter_end: &str) -> bool {
268    let normalized = doc_date.split('T').next().unwrap_or(doc_date);
269    normalized >= filter_start && normalized <= filter_end
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn date_range_exact_date() {
278        let (ss, se, fs, fe) = date_range("2025-03-15").unwrap();
279        // Search window: ±1 month
280        assert_eq!(ss, "2025-02-01");
281        assert_eq!(se, "2025-04-28");
282        // Filter: ±7 days
283        assert!(fs.as_str() <= "2025-03-08");
284        assert!(fe.as_str() >= "2025-03-22");
285    }
286
287    #[test]
288    fn date_range_month_period() {
289        let (ss, se, fs, fe) = date_range("2025-03").unwrap();
290        assert_eq!(ss, "2025-03-01");
291        assert_eq!(se, "2025-03-31");
292        assert_eq!(fs, ss);
293        assert_eq!(fe, se);
294    }
295
296    #[test]
297    fn date_range_quarter_period() {
298        let (ss, se, fs, fe) = date_range("2025-Q1").unwrap();
299        assert_eq!(ss, "2025-01-01");
300        assert_eq!(se, "2025-03-31");
301        assert_eq!(fs, ss);
302        assert_eq!(fe, se);
303    }
304
305    #[test]
306    fn date_range_year_period() {
307        let (ss, se, fs, fe) = date_range("2025").unwrap();
308        assert_eq!(ss, "2025-01-01");
309        assert_eq!(se, "2025-12-31");
310        assert_eq!(fs, ss);
311        assert_eq!(fe, se);
312    }
313
314    #[test]
315    fn date_in_range_within() {
316        assert!(date_in_range("2025-03-15", "2025-03-01", "2025-03-31"));
317    }
318
319    #[test]
320    fn date_in_range_strips_time() {
321        assert!(date_in_range(
322            "2025-03-15T10:30:00",
323            "2025-03-01",
324            "2025-03-31"
325        ));
326    }
327
328    #[test]
329    fn date_in_range_outside() {
330        assert!(!date_in_range("2025-04-01", "2025-03-01", "2025-03-31"));
331    }
332
333    #[test]
334    fn date_in_range_boundaries() {
335        assert!(date_in_range("2025-03-01", "2025-03-01", "2025-03-31"));
336        assert!(date_in_range("2025-03-31", "2025-03-01", "2025-03-31"));
337    }
338
339    #[test]
340    fn shift_days_forward() {
341        let result = shift_days("2025-03-15", 7);
342        assert!(result.as_str() > "2025-03-15");
343    }
344
345    #[test]
346    fn shift_days_backward() {
347        let result = shift_days("2025-03-15", -7);
348        assert!(result.as_str() < "2025-03-15");
349    }
350}