envelope_cli/cli/
import.rs

1//! CLI command handler for CSV import
2//!
3//! Handles importing transactions from CSV files with automatic
4//! column mapping detection and duplicate checking.
5
6use std::path::Path;
7
8use crate::error::{EnvelopeError, EnvelopeResult};
9use crate::models::{Account, AccountId};
10use crate::services::{
11    AccountService, ImportPreviewEntry, ImportService, ImportStatus, ParsedTransaction,
12};
13use crate::storage::Storage;
14
15/// Handle the import command
16pub fn handle_import_command(storage: &Storage, file: &str, account: &str) -> EnvelopeResult<()> {
17    let account_service = AccountService::new(storage);
18    let import_service = ImportService::new(storage);
19
20    let (parsed, target_account) =
21        read_and_parse_csv(&import_service, &account_service, file, account)?;
22
23    if parsed.is_empty() {
24        println!("No transactions found in CSV file.");
25        return Ok(());
26    }
27
28    let preview = generate_and_display_preview(&import_service, &parsed, &target_account)?;
29
30    let new_count = preview
31        .iter()
32        .filter(|e| e.status == ImportStatus::New)
33        .count();
34
35    if new_count > 0 {
36        execute_import(&import_service, &preview, target_account.id)?;
37    }
38
39    Ok(())
40}
41
42/// Read and parse CSV file, returning parsed transactions and target account
43fn read_and_parse_csv(
44    import_service: &ImportService,
45    account_service: &AccountService,
46    file: &str,
47    account: &str,
48) -> EnvelopeResult<(Vec<Result<ParsedTransaction, String>>, Account)> {
49    let target_account = account_service
50        .find(account)?
51        .ok_or_else(|| EnvelopeError::account_not_found(account))?;
52
53    let path = Path::new(file);
54    if !path.exists() {
55        return Err(EnvelopeError::Import(format!("File not found: {}", file)));
56    }
57
58    // First, peek at the file to detect the format
59    let mut reader = csv::Reader::from_path(path)
60        .map_err(|e| EnvelopeError::Import(format!("Failed to open CSV file: {}", e)))?;
61    let headers = reader
62        .headers()
63        .map_err(|e| EnvelopeError::Import(format!("Failed to read CSV headers: {}", e)))?
64        .clone();
65    let mapping = import_service.detect_mapping_from_headers(&headers);
66
67    // If no header detected, re-read without treating first row as header
68    let parsed = if !mapping.has_header {
69        let mut reader = csv::ReaderBuilder::new()
70            .has_headers(false)
71            .from_path(path)
72            .map_err(|e| EnvelopeError::Import(format!("Failed to open CSV file: {}", e)))?;
73        import_service.parse_csv_from_reader(&mut reader, &mapping)?
74    } else {
75        import_service.parse_csv_from_reader(&mut reader, &mapping)?
76    };
77
78    Ok((parsed, target_account))
79}
80
81/// Generate import preview and display summary to user
82fn generate_and_display_preview(
83    import_service: &ImportService,
84    parsed: &[Result<ParsedTransaction, String>],
85    target_account: &Account,
86) -> EnvelopeResult<Vec<ImportPreviewEntry>> {
87    let preview = import_service.generate_preview(parsed, target_account.id)?;
88
89    let new_count = preview
90        .iter()
91        .filter(|e| e.status == ImportStatus::New)
92        .count();
93    let dup_count = preview
94        .iter()
95        .filter(|e| e.status == ImportStatus::Duplicate)
96        .count();
97    let err_count = preview
98        .iter()
99        .filter(|e| matches!(e.status, ImportStatus::Error(_)))
100        .count();
101
102    println!("Import Preview for '{}'", target_account.name);
103    println!("{}", "=".repeat(40));
104    println!("  New transactions:   {}", new_count);
105    println!("  Duplicates (skip):  {}", dup_count);
106    println!("  Errors:             {}", err_count);
107    println!();
108
109    if new_count == 0 {
110        println!("No new transactions to import.");
111    } else {
112        println!("First transactions to import:");
113        for entry in preview
114            .iter()
115            .filter(|e| e.status == ImportStatus::New)
116            .take(5)
117        {
118            println!(
119                "  {} {} {}",
120                entry.transaction.date, entry.transaction.payee, entry.transaction.amount
121            );
122        }
123        if new_count > 5 {
124            println!("  ... and {} more", new_count - 5);
125        }
126        println!();
127    }
128
129    Ok(preview)
130}
131
132/// Execute the import and display results
133fn execute_import(
134    import_service: &ImportService,
135    preview: &[ImportPreviewEntry],
136    account_id: AccountId,
137) -> EnvelopeResult<()> {
138    let result = import_service.import_from_preview(
139        preview, account_id, None,  // No default category
140        false, // Don't mark as cleared
141    )?;
142
143    println!("Import Complete!");
144    println!("  Imported:    {}", result.imported);
145    println!("  Skipped:     {}", result.duplicates_skipped);
146    if !result.error_messages.is_empty() {
147        println!("  Errors:      {}", result.errors);
148        for (row, msg) in &result.error_messages {
149            println!("    Row {}: {}", row + 1, msg);
150        }
151    }
152
153    Ok(())
154}