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
7fn 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 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 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 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
137pub 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
219fn 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 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 let (start, end) = crate::period::parse_period(date)?;
239 Ok((start.clone(), end.clone(), start, end))
240 }
241 }
242}
243
244fn 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
266fn 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 assert_eq!(ss, "2025-02-01");
281 assert_eq!(se, "2025-04-28");
282 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}