use crate::client::archive::ArchiveClient;
use crate::config::Config;
use crate::error::YukiError;
use crate::folders;
use crate::output::{ListOptions, OutputFormat, format_json, format_table, is_tty, select_fields};
fn doc_type_id(value: &str) -> Result<i32, YukiError> {
value.trim().parse::<i32>().map_err(|_| {
YukiError::Config(format!(
"invalid document type: {value} (expected a numeric document type ID)"
))
})
}
pub async fn list(
config: &Config,
_admin: Option<&str>,
folder: Option<&str>,
doc_type: Option<&str>,
format: Option<&str>,
opts: ListOptions<'_>,
) -> Result<(), YukiError> {
let mut client = ArchiveClient::new();
client.authenticate(&config.api_key).await?;
let docs = match (folder, doc_type) {
(Some(f), _) => {
let folder_id = folders::folder_id(f)?;
client
.documents_in_folder_paged(
folder_id,
"2000-01-01",
"2099-12-31",
opts.limit,
opts.offset,
)
.await?
}
(None, Some(t)) => {
let xml = client.documents_by_type(doc_type_id(t)?).await?;
let headers = vec!["Raw XML".into()];
let rows = vec![vec![xml]];
let fmt = OutputFormat::from_flag(format, is_tty());
match fmt {
OutputFormat::Table => println!("{}", format_table(&headers, &rows)),
OutputFormat::Json => println!("{}", format_json(&headers, &rows)),
}
return Ok(());
}
(None, None) => {
client
.documents_in_folder_paged(0, "2000-01-01", "2099-12-31", opts.limit, opts.offset)
.await?
}
};
let mut headers = vec![
"ID".into(),
"Date".into(),
"Amount".into(),
"Contact".into(),
"Subject".into(),
"File".into(),
];
let mut rows: Vec<Vec<String>> = docs
.into_iter()
.map(|d| {
vec![
d.id,
d.document_date,
d.amount,
d.contact_name,
d.subject,
d.file_name,
]
})
.collect();
select_fields(&mut headers, &mut rows, &opts)?;
let fmt = OutputFormat::from_flag(format, is_tty());
match fmt {
OutputFormat::Table => println!("{}", format_table(&headers, &rows)),
OutputFormat::Json => println!("{}", format_json(&headers, &rows)),
}
Ok(())
}
pub async fn search(
config: &Config,
_admin: Option<&str>,
query: &str,
format: Option<&str>,
) -> Result<(), YukiError> {
let mut client = ArchiveClient::new();
client.authenticate(&config.api_key).await?;
let year = current_year();
let start = format!("{year}-01-01");
let end = format!("{year}-12-31");
let docs = client.search_documents(query, &start, &end).await?;
let headers = vec![
"ID".into(),
"Date".into(),
"Amount".into(),
"Contact".into(),
"Subject".into(),
"File".into(),
];
let rows: Vec<Vec<String>> = docs
.into_iter()
.map(|d| {
vec![
d.id,
d.document_date,
d.amount,
d.contact_name,
d.subject,
d.file_name,
]
})
.collect();
let fmt = OutputFormat::from_flag(format, is_tty());
match fmt {
OutputFormat::Table => println!("{}", format_table(&headers, &rows)),
OutputFormat::Json => println!("{}", format_json(&headers, &rows)),
}
Ok(())
}
pub async fn exists(
config: &Config,
_admin: Option<&str>,
amount: f64,
date: &str,
contact: Option<&str>,
format: Option<&str>,
) -> Result<(), YukiError> {
let (search_start, search_end, filter_start, filter_end) = date_range(date)?;
let mut client = ArchiveClient::new();
client.authenticate(&config.api_key).await?;
let search_text = contact.unwrap_or("");
let docs = client
.search_documents(search_text, &search_start, &search_end)
.await?;
let matched: Vec<_> = docs
.into_iter()
.filter(|d| {
let amount_matches = d
.amount
.trim()
.parse::<f64>()
.map(|a| (a - amount).abs() <= 0.01)
.unwrap_or(false);
let date_matches = date_in_range(&d.document_date, &filter_start, &filter_end);
amount_matches && date_matches
})
.collect();
let headers = vec![
"ID".into(),
"Date".into(),
"Amount".into(),
"Contact".into(),
"Subject".into(),
"File".into(),
];
let rows: Vec<Vec<String>> = matched
.iter()
.map(|d| {
vec![
d.id.clone(),
d.document_date.clone(),
d.amount.clone(),
d.contact_name.clone(),
d.subject.clone(),
d.file_name.clone(),
]
})
.collect();
let fmt = OutputFormat::from_flag(format, is_tty());
match fmt {
OutputFormat::Table => println!("{}", format_table(&headers, &rows)),
OutputFormat::Json => println!("{}", format_json(&headers, &rows)),
}
if rows.is_empty() {
return Err(YukiError::NotFound(
"no matching document found".to_string(),
));
}
Ok(())
}
fn current_year() -> u32 {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
1970 + (secs / 31_557_600) as u32
}
fn date_range(date: &str) -> Result<(String, String, String, String), YukiError> {
let parts: Vec<&str> = date.split('-').collect();
match parts.len() {
3 => {
let y: i32 = parts[0].parse().unwrap_or(0);
let m: i32 = parts[1].parse().unwrap_or(0);
let search_start = format!("{y:04}-{:02}-01", (m - 1).max(1));
let search_end = format!("{y:04}-{:02}-28", (m + 1).min(12));
let filter_start = shift_days(date, -7);
let filter_end = shift_days(date, 7);
Ok((search_start, search_end, filter_start, filter_end))
}
_ => {
let (start, end) = crate::period::parse_period(date)?;
Ok((start.clone(), end.clone(), start, end))
}
}
}
fn shift_days(date: &str, days: i32) -> String {
let to_days = |s: &str| -> i32 {
let p: Vec<&str> = s.split('-').collect();
if p.len() < 3 {
return 0;
}
let y: i32 = p[0].parse().unwrap_or(0);
let m: i32 = p[1].parse().unwrap_or(0);
let d: i32 = p[2].parse().unwrap_or(0);
y * 365 + m * 30 + d
};
let from_days = |total: i32| -> String {
let y = total / 365;
let rem = total % 365;
let m = (rem / 30).clamp(1, 12);
let d = (rem % 30).clamp(1, 28);
format!("{y:04}-{m:02}-{d:02}")
};
from_days(to_days(date) + days)
}
fn date_in_range(doc_date: &str, filter_start: &str, filter_end: &str) -> bool {
let normalized = doc_date.split('T').next().unwrap_or(doc_date);
normalized >= filter_start && normalized <= filter_end
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn date_range_exact_date() {
let (ss, se, fs, fe) = date_range("2025-03-15").unwrap();
assert_eq!(ss, "2025-02-01");
assert_eq!(se, "2025-04-28");
assert!(fs.as_str() <= "2025-03-08");
assert!(fe.as_str() >= "2025-03-22");
}
#[test]
fn date_range_month_period() {
let (ss, se, fs, fe) = date_range("2025-03").unwrap();
assert_eq!(ss, "2025-03-01");
assert_eq!(se, "2025-03-31");
assert_eq!(fs, ss);
assert_eq!(fe, se);
}
#[test]
fn date_range_quarter_period() {
let (ss, se, fs, fe) = date_range("2025-Q1").unwrap();
assert_eq!(ss, "2025-01-01");
assert_eq!(se, "2025-03-31");
assert_eq!(fs, ss);
assert_eq!(fe, se);
}
#[test]
fn date_range_year_period() {
let (ss, se, fs, fe) = date_range("2025").unwrap();
assert_eq!(ss, "2025-01-01");
assert_eq!(se, "2025-12-31");
assert_eq!(fs, ss);
assert_eq!(fe, se);
}
#[test]
fn date_in_range_within() {
assert!(date_in_range("2025-03-15", "2025-03-01", "2025-03-31"));
}
#[test]
fn date_in_range_strips_time() {
assert!(date_in_range(
"2025-03-15T10:30:00",
"2025-03-01",
"2025-03-31"
));
}
#[test]
fn date_in_range_outside() {
assert!(!date_in_range("2025-04-01", "2025-03-01", "2025-03-31"));
}
#[test]
fn date_in_range_boundaries() {
assert!(date_in_range("2025-03-01", "2025-03-01", "2025-03-31"));
assert!(date_in_range("2025-03-31", "2025-03-01", "2025-03-31"));
}
#[test]
fn shift_days_forward() {
let result = shift_days("2025-03-15", 7);
assert!(result.as_str() > "2025-03-15");
}
#[test]
fn shift_days_backward() {
let result = shift_days("2025-03-15", -7);
assert!(result.as_str() < "2025-03-15");
}
}