Skip to main content

yuki_cli/cli/
documents.rs

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