1use subx_core::error::SubXError;
23
24pub trait SubXErrorExt {
26 fn exit_code(&self) -> i32;
36
37 fn user_friendly_message(&self) -> String;
48}
49
50impl SubXErrorExt for SubXError {
51 fn exit_code(&self) -> i32 {
52 match self {
53 SubXError::Io(_) => 1,
54 SubXError::Config { .. } => 2,
55 SubXError::Api { .. } => 3,
56 SubXError::AiService(_) => 3,
57 SubXError::SubtitleFormat { .. } => 4,
58 SubXError::AudioProcessing { .. } => 5,
59 SubXError::FileMatching { .. } => 6,
60 _ => 1,
61 }
62 }
63
64 fn user_friendly_message(&self) -> String {
65 match self {
66 SubXError::Io(e) => format!("File operation error: {}", e),
67 SubXError::Config { message } => format!(
68 "Configuration error: {}\nHint: run 'subx-cli config --help' for details",
69 message
70 ),
71 SubXError::Api { message, source } => format!(
72 "API error ({:?}): {}\nHint: check network connection and API key settings",
73 source, message
74 ),
75 SubXError::AiService(msg) => format!(
76 "AI service error: {}\nHint: check network connection and API key settings",
77 msg
78 ),
79 SubXError::SubtitleFormat { message, .. } => format!(
80 "Subtitle processing error: {}\nHint: check file format and encoding",
81 message
82 ),
83 SubXError::AudioProcessing { message } => format!(
84 "Audio processing error: {}\nHint: ensure media file integrity and support",
85 message
86 ),
87 SubXError::FileMatching { message } => format!(
88 "File matching error: {}\nHint: verify file paths and patterns",
89 message
90 ),
91 SubXError::FileAlreadyExists(path) => format!("File already exists: {}", path),
92 SubXError::FileNotFound(path) => format!("File not found: {}", path),
93 SubXError::InvalidFileName(name) => format!("Invalid file name: {}", name),
94 SubXError::FileOperationFailed(msg) => format!("File operation failed: {}", msg),
95 SubXError::CommandExecution(msg) => msg.clone(),
96 SubXError::OutputModeUnsupported { command } => format!(
97 "The '{}' command does not support --output json; its stdout is a shell-completion script.\nHint: rerun without --output json (and ensure SUBX_OUTPUT is unset)",
98 command
99 ),
100 SubXError::Other(err) => {
101 format!("Unknown error: {}\nHint: please report this issue", err)
102 }
103 _ => format!("Error: {}", self),
104 }
105 }
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111 use std::io;
112 use std::path::PathBuf;
113 use subx_core::error::ApiErrorSource;
114
115 #[test]
123 fn test_exit_codes() {
124 assert_eq!(SubXError::config("test").exit_code(), 2);
125 assert_eq!(SubXError::subtitle_format("SRT", "test").exit_code(), 4);
126 assert_eq!(SubXError::audio_processing("test").exit_code(), 5);
127 assert_eq!(SubXError::file_matching("test").exit_code(), 6);
128 }
129
130 #[test]
131 fn test_user_friendly_messages() {
132 let config_error = SubXError::config("missing key");
133 let message = config_error.user_friendly_message();
134 assert!(message.contains("Configuration error:"));
135 assert!(message.contains("subx-cli config --help"));
136
137 let ai_error = SubXError::ai_service("network failure".to_string());
138 let message = ai_error.user_friendly_message();
139 assert!(message.contains("AI service error:"));
140 assert!(message.contains("check network connection"));
141 }
142
143 #[test]
161 fn test_no_api_key_leaks_in_any_variant() {
162 use std::path::PathBuf;
163 use subx_core::services::ai::error_sanitizer::{
164 DEFAULT_ERROR_BODY_MAX_LEN, sanitize_url_in_error, truncate_error_body,
165 };
166
167 let variants: Vec<SubXError> = vec![
170 SubXError::Io(io::Error::other("disk error")),
171 SubXError::Config {
172 message: "missing key".to_string(),
173 },
174 SubXError::SubtitleFormat {
175 format: "SRT".to_string(),
176 message: "bad timestamp".to_string(),
177 },
178 SubXError::AiService("upstream service failed".to_string()),
179 SubXError::Api {
180 message: "auth failed".to_string(),
181 source: ApiErrorSource::OpenAI,
182 },
183 SubXError::AudioProcessing {
184 message: "codec failure".to_string(),
185 },
186 SubXError::FileMatching {
187 message: "pattern mismatch".to_string(),
188 },
189 SubXError::FileAlreadyExists("/tmp/example".to_string()),
190 SubXError::FileNotFound("/tmp/example".to_string()),
191 SubXError::InvalidFileName("bad?name".to_string()),
192 SubXError::FileOperationFailed("rename failed".to_string()),
193 SubXError::CommandExecution("exit 1".to_string()),
194 SubXError::NoInputSpecified,
195 SubXError::InvalidPath(PathBuf::from("/tmp/example")),
196 SubXError::PathNotFound(PathBuf::from("/tmp/example")),
197 SubXError::DirectoryReadError {
198 path: PathBuf::from("/tmp/example"),
199 source: io::Error::other("denied"),
200 },
201 SubXError::InvalidSyncConfiguration,
202 SubXError::UnsupportedFileType("xyz".to_string()),
203 SubXError::OutputModeUnsupported {
204 command: "generate-completion".to_string(),
205 },
206 SubXError::Other(anyhow::anyhow!("wrapped")),
207 ];
208
209 for err in &variants {
210 let display = format!("{}", err);
211 let debug = format!("{:?}", err);
212 let friendly = err.user_friendly_message();
213 for (label, text) in [
214 ("Display", &display),
215 ("Debug", &debug),
216 ("friendly", &friendly),
217 ] {
218 assert!(
219 !text.contains("sk-"),
220 "{} surface for variant {:?} contains `sk-` prefix: {}",
221 label,
222 err,
223 text
224 );
225 }
226 }
227
228 const SECRET: &str = "sk-test-key-12345";
232 let upstream_body = format!(
233 "{{\"error\": \"invalid\", \"echoed\": \"Bearer {}\"}}",
234 SECRET
235 );
236 let truncated = truncate_error_body(&upstream_body, DEFAULT_ERROR_BODY_MAX_LEN);
237 assert!(truncated.contains(SECRET));
241
242 let url_leak = format!(
243 "request error: https://api.example.com/v1/chat?api-key={}",
244 SECRET
245 );
246 let cleaned = sanitize_url_in_error(&url_leak);
247 assert!(!cleaned.contains("sk-test-key"));
248 let wrapped = SubXError::AiService(cleaned);
249 assert!(!format!("{}", wrapped).contains("sk-test-key"));
250 assert!(!format!("{:?}", wrapped).contains("sk-test-key"));
251 }
252
253 #[test]
256 fn test_exit_code_io() {
257 let err = SubXError::Io(io::Error::new(io::ErrorKind::NotFound, "x"));
258 assert_eq!(err.exit_code(), 1);
259 }
260
261 #[test]
262 fn test_exit_code_api() {
263 let err = SubXError::Api {
264 message: "x".to_string(),
265 source: ApiErrorSource::OpenAI,
266 };
267 assert_eq!(err.exit_code(), 3);
268 }
269
270 #[test]
271 fn test_exit_code_ai_service() {
272 let err = SubXError::AiService("x".to_string());
273 assert_eq!(err.exit_code(), 3);
274 }
275
276 #[test]
277 fn test_exit_code_catchall_variants() {
278 assert_eq!(SubXError::FileAlreadyExists("f".to_string()).exit_code(), 1);
279 assert_eq!(SubXError::FileNotFound("f".to_string()).exit_code(), 1);
280 assert_eq!(SubXError::InvalidFileName("f".to_string()).exit_code(), 1);
281 assert_eq!(
282 SubXError::FileOperationFailed("f".to_string()).exit_code(),
283 1
284 );
285 assert_eq!(SubXError::CommandExecution("f".to_string()).exit_code(), 1);
286 assert_eq!(SubXError::NoInputSpecified.exit_code(), 1);
287 assert_eq!(SubXError::InvalidPath(PathBuf::from("/x")).exit_code(), 1);
288 assert_eq!(SubXError::PathNotFound(PathBuf::from("/x")).exit_code(), 1);
289 assert_eq!(SubXError::InvalidSyncConfiguration.exit_code(), 1);
290 assert_eq!(
291 SubXError::UnsupportedFileType("xyz".to_string()).exit_code(),
292 1
293 );
294 assert_eq!(SubXError::Other(anyhow::anyhow!("other")).exit_code(), 1);
295 }
296
297 #[test]
306 fn test_category_and_machine_code_contract() {
307 let cases: Vec<(SubXError, &'static str, &'static str, i32)> = vec![
308 (SubXError::Io(io::Error::other("x")), "io", "E_IO", 1),
309 (
310 SubXError::Config {
311 message: "x".into(),
312 },
313 "config",
314 "E_CONFIG",
315 2,
316 ),
317 (
318 SubXError::SubtitleFormat {
319 format: "SRT".into(),
320 message: "x".into(),
321 },
322 "subtitle_format",
323 "E_SUBTITLE_FORMAT",
324 4,
325 ),
326 (
327 SubXError::AiService("x".into()),
328 "ai_service",
329 "E_AI_SERVICE",
330 3,
331 ),
332 (
333 SubXError::Api {
334 message: "x".into(),
335 source: ApiErrorSource::OpenAI,
336 },
337 "api",
338 "E_API",
339 3,
340 ),
341 (
342 SubXError::AudioProcessing {
343 message: "x".into(),
344 },
345 "audio_processing",
346 "E_AUDIO_PROCESSING",
347 5,
348 ),
349 (
350 SubXError::FileMatching {
351 message: "x".into(),
352 },
353 "file_matching",
354 "E_FILE_MATCHING",
355 6,
356 ),
357 (
358 SubXError::FileAlreadyExists("x".into()),
359 "file_already_exists",
360 "E_FILE_ALREADY_EXISTS",
361 1,
362 ),
363 (
364 SubXError::FileNotFound("x".into()),
365 "file_not_found",
366 "E_FILE_NOT_FOUND",
367 1,
368 ),
369 (
370 SubXError::InvalidFileName("x".into()),
371 "invalid_file_name",
372 "E_INVALID_FILE_NAME",
373 1,
374 ),
375 (
376 SubXError::FileOperationFailed("x".into()),
377 "file_operation_failed",
378 "E_FILE_OPERATION_FAILED",
379 1,
380 ),
381 (
382 SubXError::CommandExecution("x".into()),
383 "command_execution",
384 "E_COMMAND_EXECUTION",
385 1,
386 ),
387 (
388 SubXError::OutputModeUnsupported {
389 command: "generate-completion".into(),
390 },
391 "command_execution",
392 "E_OUTPUT_MODE_UNSUPPORTED",
393 1,
394 ),
395 (
396 SubXError::NoInputSpecified,
397 "no_input_specified",
398 "E_NO_INPUT_SPECIFIED",
399 1,
400 ),
401 (
402 SubXError::InvalidPath(PathBuf::from("/x")),
403 "invalid_path",
404 "E_INVALID_PATH",
405 1,
406 ),
407 (
408 SubXError::PathNotFound(PathBuf::from("/x")),
409 "path_not_found",
410 "E_PATH_NOT_FOUND",
411 1,
412 ),
413 (
414 SubXError::DirectoryReadError {
415 path: PathBuf::from("/x"),
416 source: io::Error::other("denied"),
417 },
418 "directory_read_error",
419 "E_DIRECTORY_READ_ERROR",
420 1,
421 ),
422 (
423 SubXError::InvalidSyncConfiguration,
424 "invalid_sync_configuration",
425 "E_INVALID_SYNC_CONFIGURATION",
426 1,
427 ),
428 (
429 SubXError::UnsupportedFileType("xyz".into()),
430 "unsupported_file_type",
431 "E_UNSUPPORTED_FILE_TYPE",
432 1,
433 ),
434 (
435 SubXError::Other(anyhow::anyhow!("x")),
436 "other",
437 "E_OTHER",
438 1,
439 ),
440 ];
441
442 for (err, cat, code, exit) in &cases {
443 assert_eq!(err.category(), *cat, "category mismatch for {:?}", err);
444 assert_eq!(
445 err.machine_code(),
446 *code,
447 "machine_code mismatch for {:?}",
448 err
449 );
450 assert_eq!(err.exit_code(), *exit, "exit_code mismatch for {:?}", err);
451 assert!(!err.category().is_empty());
452 assert!(err.machine_code().starts_with("E_"));
453 }
454 }
455
456 #[test]
459 fn test_user_friendly_message_io() {
460 let err = SubXError::Io(io::Error::new(io::ErrorKind::PermissionDenied, "denied"));
461 let msg = err.user_friendly_message();
462 assert!(msg.contains("File operation error:"));
463 assert!(msg.contains("denied"));
464 }
465
466 #[test]
467 fn test_user_friendly_message_api() {
468 let err = SubXError::Api {
469 message: "forbidden".to_string(),
470 source: ApiErrorSource::OpenAI,
471 };
472 let msg = err.user_friendly_message();
473 assert!(msg.contains("API error"));
474 assert!(msg.contains("forbidden"));
475 assert!(msg.contains("check network connection"));
476 }
477
478 #[test]
479 fn test_user_friendly_message_subtitle_format() {
480 let err = SubXError::subtitle_format("ASS", "bad encoding");
481 let msg = err.user_friendly_message();
482 assert!(msg.contains("Subtitle processing error:"));
483 assert!(msg.contains("bad encoding"));
484 assert!(msg.contains("check file format"));
485 }
486
487 #[test]
488 fn test_user_friendly_message_audio_processing() {
489 let err = SubXError::audio_processing("corrupt frame");
490 let msg = err.user_friendly_message();
491 assert!(msg.contains("Audio processing error:"));
492 assert!(msg.contains("corrupt frame"));
493 assert!(msg.contains("media file integrity"));
494 }
495
496 #[test]
497 fn test_user_friendly_message_file_matching() {
498 let err = SubXError::file_matching("pattern mismatch");
499 let msg = err.user_friendly_message();
500 assert!(msg.contains("File matching error:"));
501 assert!(msg.contains("pattern mismatch"));
502 assert!(msg.contains("verify file paths"));
503 }
504
505 #[test]
506 fn test_user_friendly_message_file_already_exists() {
507 let err = SubXError::FileAlreadyExists("output.srt".to_string());
508 assert_eq!(
509 err.user_friendly_message(),
510 "File already exists: output.srt"
511 );
512 }
513
514 #[test]
515 fn test_user_friendly_message_file_not_found() {
516 let err = SubXError::FileNotFound("input.srt".to_string());
517 assert_eq!(err.user_friendly_message(), "File not found: input.srt");
518 }
519
520 #[test]
521 fn test_user_friendly_message_invalid_file_name() {
522 let err = SubXError::InvalidFileName("bad?name".to_string());
523 assert_eq!(err.user_friendly_message(), "Invalid file name: bad?name");
524 }
525
526 #[test]
527 fn test_user_friendly_message_file_operation_failed() {
528 let err = SubXError::FileOperationFailed("rename failed".to_string());
529 assert_eq!(
530 err.user_friendly_message(),
531 "File operation failed: rename failed"
532 );
533 }
534
535 #[test]
536 fn test_user_friendly_message_command_execution() {
537 let err = SubXError::CommandExecution("process died".to_string());
538 assert_eq!(err.user_friendly_message(), "process died");
539 }
540
541 #[test]
542 fn test_user_friendly_message_other() {
543 let err = SubXError::Other(anyhow::anyhow!("mystery"));
544 let msg = err.user_friendly_message();
545 assert!(msg.contains("Unknown error:"));
546 assert!(msg.contains("mystery"));
547 assert!(msg.contains("please report this issue"));
548 }
549
550 #[test]
551 fn test_user_friendly_message_catchall_variants() {
552 let cases: Vec<SubXError> = vec![
554 SubXError::NoInputSpecified,
555 SubXError::InvalidPath(PathBuf::from("/bad")),
556 SubXError::PathNotFound(PathBuf::from("/missing")),
557 SubXError::DirectoryReadError {
558 path: PathBuf::from("/locked"),
559 source: io::Error::new(io::ErrorKind::PermissionDenied, "denied"),
560 },
561 SubXError::InvalidSyncConfiguration,
562 SubXError::UnsupportedFileType("xyz".to_string()),
563 ];
564 for err in &cases {
565 let msg = err.user_friendly_message();
566 assert!(
567 msg.starts_with("Error:"),
568 "Expected 'Error:' prefix for {:?}, got: {}",
569 err,
570 msg
571 );
572 }
573 }
574
575 #[test]
586 fn file_operation_failed_display_equals_user_friendly_message() {
587 let err = SubXError::FileOperationFailed("could not rename".into());
588 assert_eq!(err.to_string(), err.user_friendly_message());
589 assert!(err.hint().is_none());
590 }
591}