diff --git a/src/session.rs b/src/session.rs index 93d15d7..1f7e2eb 100644 --- a/src/session.rs +++ b/src/session.rs @@ -513,9 +513,7 @@ fn cmd_session_export( includes: crate::export::parse_export_includes(include)?, }; if let Some(path) = output { - ensure_parent_dir(&path)?; - let file = fs::File::create(&path)?; - crate::export::write_jsonl(&store, &options, file)?; + write_jsonl_file(&store, &options, &path)?; } else { let stdout = std::io::stdout(); let handle = stdout.lock(); @@ -984,6 +982,65 @@ fn ensure_parent_dir(path: &Path) -> Result<()> { Ok(()) } +fn write_jsonl_file(store: &Store, options: &ExportOptions, path: &Path) -> Result<()> { + ensure_parent_dir(path)?; + let target = resolve_output_target(path)?; + let path = target.as_path(); + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + #[cfg(unix)] + let mut builder = tempfile::Builder::new(); + #[cfg(not(unix))] + let builder = tempfile::Builder::new(); + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + #[cfg(unix)] + let existing_permissions = match fs::metadata(path) { + Ok(metadata) => Some(fs::Permissions::from_mode(metadata.permissions().mode() & 0o777)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(error.into()), + }; + #[cfg(unix)] + if existing_permissions.is_none() { + builder.permissions(fs::Permissions::from_mode(0o666)); + } + let mut temp = builder.tempfile_in(parent)?; + crate::export::write_jsonl(store, options, temp.as_file_mut())?; + #[cfg(unix)] + if let Some(permissions) = existing_permissions { + temp.as_file().set_permissions(permissions)?; + } + temp.as_file().sync_all()?; + temp.persist(path).map_err(|error| error.error)?; + Ok(()) +} + +fn resolve_output_target(path: &Path) -> Result { + let mut target = path.to_path_buf(); + for _ in 0..40 { + match fs::symlink_metadata(&target) { + Ok(metadata) if metadata.file_type().is_symlink() => { + let link = fs::read_link(&target)?; + target = if link.is_absolute() { + link + } else { + target + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")) + .join(link) + }; + } + Ok(_) => return Ok(target), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(target), + Err(error) => return Err(error.into()), + } + } + anyhow::bail!("too many symbolic links in output path: {}", path.display()) +} + fn read_tldr_file(path: &Path) -> Option { match fs::read_to_string(path) { Ok(content) => { @@ -1037,4 +1094,51 @@ mod tests { let _ = fs::remove_file(path); } + + #[test] + fn jsonl_file_export_replaces_target_only_after_success() { + let dir = tempfile::tempdir().unwrap(); + let target_path = dir.path().join("export.jsonl"); + fs::write(&target_path, "keep").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&target_path, fs::Permissions::from_mode(0o640)).unwrap(); + } + #[cfg(unix)] + let path = { + let path = dir.path().join("export-link.jsonl"); + std::os::unix::fs::symlink("export.jsonl", &path).unwrap(); + path + }; + #[cfg(not(unix))] + let path = target_path.clone(); + crate::db::schema::register_sqlite_vec(); + let store = Store::open_in_memory().unwrap(); + let mut options = ExportOptions { + session_ids: vec!["missing".to_string()], + sources: None, + time_range: TimeRange::All, + scope: ProjectScope::Global, + thread_role: None, + limit: None, + includes: ExportIncludes::full(), + }; + + assert!(write_jsonl_file(&store, &options, &path).is_err()); + assert_eq!(fs::read_to_string(&target_path).unwrap(), "keep"); + + options.session_ids.clear(); + write_jsonl_file(&store, &options, &path).unwrap(); + assert_eq!(fs::read_to_string(&target_path).unwrap(), ""); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert!(fs::symlink_metadata(&path).unwrap().file_type().is_symlink()); + assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 2); + assert_eq!(fs::metadata(target_path).unwrap().permissions().mode() & 0o777, 0o640); + } + #[cfg(not(unix))] + assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1); + } }