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