diff --git a/src/error.rs b/src/error.rs index 06a264b..97850aa 100644 --- a/src/error.rs +++ b/src/error.rs @@ -27,6 +27,9 @@ pub enum DtooError { #[error("Failed to read {path}: {source}")] FileRead { path: String, source: BoxError }, + #[error("Permission denied: {path}")] + PermissionDenied { path: String }, + #[error("Failed to process {path}: {message}")] FileProcess { path: String, message: String }, diff --git a/src/fingerprint.rs b/src/fingerprint.rs index 43a0627..993e57b 100644 --- a/src/fingerprint.rs +++ b/src/fingerprint.rs @@ -17,19 +17,14 @@ pub fn fingerprint_file(path: &Path) -> Result { path.to_path_buf() }; - let mut file = File::open(&target).map_err(|source| DtooError::FileRead { - path: target.display().to_string(), - source: Box::new(source), - })?; + let display_path = path.to_string_lossy().to_string(); + let mut file = File::open(&target).map_err(|source| map_io_error(&display_path, source))?; let mut hasher = Sha256::new(); let mut buffer = [0u8; 8192]; loop { let bytes = file .read(&mut buffer) - .map_err(|source| DtooError::FileRead { - path: target.display().to_string(), - source: Box::new(source), - })?; + .map_err(|source| map_io_error(&display_path, source))?; if bytes == 0 { break; } @@ -43,6 +38,21 @@ pub fn fingerprint_file(path: &Path) -> Result { Ok(format!("sha256:{}", hex::encode(hasher.finalize()))) } +/// Return the display file name used in `dtoo fingerprint` output. +pub fn display_name(path: &Path) -> String { + if is_cloud_path(path) { + let value = path.to_string_lossy(); + if let Some(last) = value.rsplit('/').find(|segment| !segment.is_empty()) { + return last.to_string(); + } + return value.to_string(); + } + + path.file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| path.to_string_lossy().to_string()) +} + fn is_cloud_path(path: &Path) -> bool { let value = path.to_string_lossy(); value.starts_with("s3://") @@ -68,13 +78,19 @@ fn download_cloud_blob(path: &Path) -> Result { sql: sql.clone(), source: Box::new(source), })?; - let bytes: Vec = stmt - .query_row([], |row| row.get(0)) - .map_err(|source| DtooError::Sql { + let bytes: Vec = stmt.query_row([], |row| row.get(0)).map_err(|source| { + let rendered = source.to_string(); + if should_map_missing_credentials(&rendered) { + return DtooError::Config { + message: format!("{} credentials not configured", cloud_provider(path)), + }; + } + DtooError::Sql { context: "fingerprint".to_string(), sql, source: Box::new(source), - })?; + } + })?; let temp = std::env::temp_dir().join(format!( "dtoo-fingerprint-{}.bin", @@ -94,6 +110,41 @@ fn escape_sql_literal(input: &str) -> String { input.replace('\'', "''") } +fn map_io_error(path: &str, source: std::io::Error) -> DtooError { + match source.kind() { + std::io::ErrorKind::NotFound => DtooError::FileNotFound { + path: path.to_string(), + }, + std::io::ErrorKind::PermissionDenied => DtooError::PermissionDenied { + path: path.to_string(), + }, + _ => DtooError::FileRead { + path: path.to_string(), + source: Box::new(source), + }, + } +} + +fn should_map_missing_credentials(message: &str) -> bool { + let lower = message.to_ascii_lowercase(); + lower.contains("credential") + || lower.contains("access key") + || lower.contains("secret key") + || lower.contains("no provider") + || lower.contains("authorization") +} + +fn cloud_provider(path: &Path) -> &'static str { + let value = path.to_string_lossy(); + if value.starts_with("s3://") { + "s3" + } else if value.starts_with("gs://") { + "gcs" + } else { + "azure" + } +} + #[cfg(test)] mod tests { use super::*; @@ -112,4 +163,23 @@ mod tests { assert!(hash.starts_with("sha256:")); std::fs::remove_file(path).ok(); } + + #[test] + fn returns_file_not_found_error_for_missing_file() { + let missing = PathBuf::from("/tmp/dtoo-missing-fingerprint-input.bin"); + let err = fingerprint_file(&missing).expect_err("missing file should fail"); + assert!(matches!(err, DtooError::FileNotFound { .. })); + } + + #[test] + fn display_name_uses_basename_for_local_path() { + let name = display_name(Path::new("/tmp/data/sales.parquet")); + assert_eq!(name, "sales.parquet"); + } + + #[test] + fn display_name_uses_last_segment_for_cloud_path() { + let name = display_name(Path::new("s3://bucket/path/to/sales.parquet")); + assert_eq!(name, "sales.parquet"); + } } diff --git a/src/main.rs b/src/main.rs index 9d355bc..0204064 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,7 +34,7 @@ use std::process::ExitCode; use cli::{Cli, Commands, QueryArgs}; use error::DtooError; -use fingerprint::fingerprint_file; +use fingerprint::{display_name as fingerprint_display_name, fingerprint_file}; use inspect::run as run_inspect; use profile_command::run as run_profile; use query_pipeline::QueryPipeline; @@ -72,7 +72,7 @@ fn dispatch(cli: Cli) -> Result<(), DtooError> { Commands::Inspect(args) => run_inspect(&args), Commands::Fingerprint(args) => { let hash = fingerprint_file(&args.path)?; - println!("{hash} {}", args.path.display()); + println!("{hash} {}", fingerprint_display_name(&args.path)); Ok(()) } }