chamber_api/handlers/
import_export.rs1use 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, }
61
62pub 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 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 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
136pub 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 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 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
201pub 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 let new_items = import_items(&path, &format).map_err(|e| ApiError::InternalError(format!("Import failed: {e}")))?;
231
232 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}