Skip to main content

chamber_api/handlers/
import_export.rs

1use axum::Json;
2use axum::extract::State;
3use std::collections::HashSet;
4use std::path::PathBuf;
5use std::sync::Arc;
6
7use crate::auth::AuthenticatedUser;
8use crate::error::{ApiError, ApiResult};
9use crate::models::ApiResponse;
10use crate::server::AppState;
11use chamber_import_export::{ExportFormat, export_items, import_items};
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Deserialize)]
15pub struct ImportRequest {
16    pub format: String,
17    pub path: String,
18}
19
20#[derive(Debug, Deserialize)]
21pub struct ExportRequest {
22    pub format: String,
23    pub path: String,
24    #[serde(default)]
25    pub filter: Option<ExportFilter>,
26}
27
28#[derive(Debug, Deserialize)]
29pub struct ExportFilter {
30    pub kind: Option<String>,
31    pub query: Option<String>,
32}
33
34#[derive(Debug, Serialize)]
35pub struct ImportResponse {
36    pub imported: usize,
37    pub skipped: usize,
38    pub report: Vec<String>,
39}
40
41#[derive(Debug, Serialize)]
42pub struct ExportResponse {
43    pub count: usize,
44    pub path: String,
45}
46
47#[derive(Debug, Serialize)]
48pub struct DryRunResponse {
49    pub would_import: usize,
50    pub would_skip: usize,
51    pub conflicts: Vec<String>,
52    pub preview: Vec<ItemPreview>,
53}
54
55#[derive(Debug, Serialize)]
56pub struct ItemPreview {
57    pub name: String,
58    pub kind: String,
59    pub status: String, // "new", "conflict", "skip"
60}
61
62/// # Errors
63///
64/// This function returns an error if:
65/// - The user does not have the required 'write:items' scope
66/// - The vault is locked
67/// - The import file does not exist
68/// - No items are found in the import file
69/// - There are issues with vault operations
70/// - The import operation fails
71pub async fn import_items_handler(
72    State(state): State<Arc<AppState>>,
73    AuthenticatedUser(claims): AuthenticatedUser,
74    Json(request): Json<ImportRequest>,
75) -> ApiResult<Json<ApiResponse<ImportResponse>>> {
76    if !claims.has_scope("write:items") {
77        return Err(ApiError::Forbidden);
78    }
79
80    if !state.auth.is_vault_unlocked() {
81        return Err(ApiError::BadRequest("Vault is locked".to_string()));
82    }
83
84    let format = parse_export_format(&request.format)?;
85    let path = PathBuf::from(&request.path);
86
87    if !path.exists() {
88        return Err(ApiError::BadRequest("File does not exist".to_string()));
89    }
90
91    // Import items from file
92    let new_items = import_items(&path, &format).map_err(|e| ApiError::InternalError(format!("Import failed: {e}")))?;
93
94    if new_items.is_empty() {
95        return Err(ApiError::BadRequest("No items found in file".to_string()));
96    }
97
98    // Get existing items to check for conflicts
99    let mut vault = state.vault.lock().await;
100    let existing_items = vault.list_items().map_err(|e| ApiError::VaultError(e.to_string()))?;
101
102    let existing_names: HashSet<String> = existing_items.iter().map(|item| item.name.clone()).collect();
103
104    let mut imported = 0;
105    let mut skipped = 0;
106    let mut report = Vec::new();
107
108    for item in new_items {
109        if existing_names.contains(&item.name) {
110            skipped += 1;
111            report.push(format!("Skipped '{}': already exists", item.name));
112            continue;
113        }
114
115        match vault.create_item(&item) {
116            Ok(()) => {
117                imported += 1;
118                report.push(format!("Imported '{}'", item.name));
119            }
120            Err(e) => {
121                skipped += 1;
122                report.push(format!("Failed to import '{}': {}", item.name, e));
123            }
124        }
125    }
126
127    let response = ImportResponse {
128        imported,
129        skipped,
130        report,
131    };
132
133    Ok(Json(ApiResponse::new(response)))
134}
135
136/// # Errors
137///
138/// This function returns an error if:
139/// - The user does not have the required 'read:items' scope
140/// - The vault is locked
141/// - Failed to create a directory for export
142/// - The vault items cannot be listed
143/// - The export operation fails
144pub async fn export_items_handler(
145    State(state): State<Arc<AppState>>,
146    AuthenticatedUser(claims): AuthenticatedUser,
147    Json(request): Json<ExportRequest>,
148) -> ApiResult<Json<ApiResponse<ExportResponse>>> {
149    if !claims.has_scope("read:items") {
150        return Err(ApiError::Forbidden);
151    }
152
153    if !state.auth.is_vault_unlocked() {
154        return Err(ApiError::BadRequest("Vault is locked".to_string()));
155    }
156
157    let format = parse_export_format(&request.format)?;
158    let path = PathBuf::from(&request.path);
159
160    // Create parent directories if they don't exist
161    if let Some(parent) = path.parent() {
162        if !parent.exists() {
163            std::fs::create_dir_all(parent)
164                .map_err(|e| ApiError::InternalError(format!("Failed to create directory: {e}")))?;
165        }
166    }
167
168    let mut items = state
169        .vault
170        .lock()
171        .await
172        .list_items()
173        .map_err(|e| ApiError::VaultError(e.to_string()))?;
174
175    // Apply filters if provided
176    if let Some(filter) = &request.filter {
177        if let Some(kind) = &filter.kind {
178            items.retain(|item| item.kind.as_str().eq_ignore_ascii_case(kind));
179        }
180
181        if let Some(query) = &filter.query {
182            let query_lower = query.to_lowercase();
183            items.retain(|item| {
184                item.name.to_lowercase().contains(&query_lower) || item.value.to_lowercase().contains(&query_lower)
185            });
186        }
187    }
188
189    let count = items.len();
190
191    export_items(&items, &format, &path).map_err(|e| ApiError::InternalError(format!("Export failed: {e}")))?;
192
193    let response = ExportResponse {
194        count,
195        path: path.to_string_lossy().to_string(),
196    };
197
198    Ok(Json(ApiResponse::new(response)))
199}
200
201/// # Errors
202///
203/// This function returns an error if:
204/// - The user does not have the required 'read:items' scope
205/// - The vault is locked
206/// - The import file does not exist
207/// - There are issues parsing the import file
208/// - The vault items cannot be listed
209pub async fn dry_run_import(
210    State(state): State<Arc<AppState>>,
211    AuthenticatedUser(claims): AuthenticatedUser,
212    Json(request): Json<ImportRequest>,
213) -> ApiResult<Json<ApiResponse<DryRunResponse>>> {
214    if !claims.has_scope("read:items") {
215        return Err(ApiError::Forbidden);
216    }
217
218    if !state.auth.is_vault_unlocked() {
219        return Err(ApiError::BadRequest("Vault is locked".to_string()));
220    }
221
222    let format = parse_export_format(&request.format)?;
223    let path = PathBuf::from(&request.path);
224
225    if !path.exists() {
226        return Err(ApiError::BadRequest("File does not exist".to_string()));
227    }
228
229    // Parse items from file
230    let new_items = import_items(&path, &format).map_err(|e| ApiError::InternalError(format!("Import failed: {e}")))?;
231
232    // Get existing items to check for conflicts
233    let existing_items = state
234        .vault
235        .lock()
236        .await
237        .list_items()
238        .map_err(|e| ApiError::VaultError(e.to_string()))?;
239
240    let existing_names: HashSet<String> = existing_items.iter().map(|item| item.name.clone()).collect();
241
242    let mut would_import = 0;
243    let mut would_skip = 0;
244    let mut conflicts = Vec::new();
245    let mut preview = Vec::new();
246
247    for item in new_items {
248        let preview_item = if existing_names.contains(&item.name) {
249            would_skip += 1;
250            conflicts.push(item.name.clone());
251            ItemPreview {
252                name: item.name,
253                kind: item.kind.as_str().to_string(),
254                status: "conflict".to_string(),
255            }
256        } else {
257            would_import += 1;
258            ItemPreview {
259                name: item.name,
260                kind: item.kind.as_str().to_string(),
261                status: "new".to_string(),
262            }
263        };
264
265        preview.push(preview_item);
266    }
267
268    let response = DryRunResponse {
269        would_import,
270        would_skip,
271        conflicts,
272        preview,
273    };
274
275    Ok(Json(ApiResponse::new(response)))
276}
277
278fn parse_export_format(format_str: &str) -> ApiResult<ExportFormat> {
279    match format_str.to_lowercase().as_str() {
280        "json" => Ok(ExportFormat::Json),
281        "csv" => Ok(ExportFormat::Csv),
282        "backup" | "chamber" => Ok(ExportFormat::ChamberBackup),
283        _ => Err(ApiError::BadRequest(
284            "Invalid format. Supported: json, csv, backup".to_string(),
285        )),
286    }
287}