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    let mut reader = csv::Reader::from_path(path)
59        .map_err(|e| EnvelopeError::Import(format!("Failed to open CSV file: {}", e)))?;
60    let headers = reader
61        .headers()
62        .map_err(|e| EnvelopeError::Import(format!("Failed to read CSV headers: {}", e)))?
63        .clone();
64    let mapping = import_service.detect_mapping_from_headers(&headers);
65
66    let parsed = import_service.parse_csv_from_reader(&mut reader, &mapping)?;
67
68    Ok((parsed, target_account))
69}
70
71/// Generate import preview and display summary to user
72fn generate_and_display_preview(
73    import_service: &ImportService,
74    parsed: &[Result<ParsedTransaction, String>],
75    target_account: &Account,
76) -> EnvelopeResult<Vec<ImportPreviewEntry>> {
77    let preview = import_service.generate_preview(parsed, target_account.id)?;
78
79    let new_count = preview
80        .iter()
81        .filter(|e| e.status == ImportStatus::New)
82        .count();
83    let dup_count = preview
84        .iter()
85        .filter(|e| e.status == ImportStatus::Duplicate)
86        .count();
87    let err_count = preview
88        .iter()
89        .filter(|e| matches!(e.status, ImportStatus::Error(_)))
90        .count();
91
92    println!("Import Preview for '{}'", target_account.name);
93    println!("{}", "=".repeat(40));
94    println!("  New transactions:   {}", new_count);
95    println!("  Duplicates (skip):  {}", dup_count);
96    println!("  Errors:             {}", err_count);
97    println!();
98
99    if new_count == 0 {
100        println!("No new transactions to import.");
101    } else {
102        println!("First transactions to import:");
103        for entry in preview
104            .iter()
105            .filter(|e| e.status == ImportStatus::New)
106            .take(5)
107        {
108            println!(
109                "  {} {} {}",
110                entry.transaction.date, entry.transaction.payee, entry.transaction.amount
111            );
112        }
113        if new_count > 5 {
114            println!("  ... and {} more", new_count - 5);
115        }
116        println!();
117    }
118
119    Ok(preview)
120}
121
122/// Execute the import and display results
123fn execute_import(
124    import_service: &ImportService,
125    preview: &[ImportPreviewEntry],
126    account_id: AccountId,
127) -> EnvelopeResult<()> {
128    let result = import_service.import_from_preview(
129        preview, account_id, None,  // No default category
130        false, // Don't mark as cleared
131    )?;
132
133    println!("Import Complete!");
134    println!("  Imported:    {}", result.imported);
135    println!("  Skipped:     {}", result.duplicates_skipped);
136    if !result.error_messages.is_empty() {
137        println!("  Errors:      {}", result.errors);
138        for (row, msg) in &result.error_messages {
139            println!("    Row {}: {}", row + 1, msg);
140        }
141    }
142
143    Ok(())
144}