uv_fs/
lib.rs

1use std::borrow::Cow;
2use std::path::{Path, PathBuf};
3
4use tempfile::NamedTempFile;
5use tracing::warn;
6
7pub use crate::locked_file::*;
8pub use crate::path::*;
9
10pub mod cachedir;
11mod locked_file;
12mod path;
13pub mod which;
14
15/// Append an extension to a [`PathBuf`].
16///
17/// Unlike [`Path::with_extension`], this function does not replace an existing extension.
18///
19/// If there is no file name, the path is returned unchanged.
20///
21/// This mimics the behavior of the unstable [`Path::with_added_extension`] method.
22pub fn with_added_extension<'a>(path: &'a Path, extension: &str) -> Cow<'a, Path> {
23    let Some(name) = path.file_name() else {
24        // If there is no file name, we cannot add an extension.
25        return Cow::Borrowed(path);
26    };
27    let mut name = name.to_os_string();
28    name.push(".");
29    name.push(extension.trim_start_matches('.'));
30    Cow::Owned(path.with_file_name(name))
31}
32
33/// Attempt to check if the two paths refer to the same file.
34///
35/// Returns `Some(true)` if the files are missing, but would be the same if they existed.
36pub fn is_same_file_allow_missing(left: &Path, right: &Path) -> Option<bool> {
37    // First, check an exact path comparison.
38    if left == right {
39        return Some(true);
40    }
41
42    // Second, check the files directly.
43    if let Ok(value) = same_file::is_same_file(left, right) {
44        return Some(value);
45    }
46
47    // Often, one of the directories won't exist yet so perform the comparison up a level.
48    if let (Some(left_parent), Some(right_parent), Some(left_name), Some(right_name)) = (
49        left.parent(),
50        right.parent(),
51        left.file_name(),
52        right.file_name(),
53    ) {
54        match same_file::is_same_file(left_parent, right_parent) {
55            Ok(true) => return Some(left_name == right_name),
56            Ok(false) => return Some(false),
57            _ => (),
58        }
59    }
60
61    // We couldn't determine if they're the same.
62    None
63}
64
65/// Reads data from the path and requires that it be valid UTF-8 or UTF-16.
66///
67/// This uses BOM sniffing to determine if the data should be transcoded
68/// from UTF-16 to Rust's `String` type (which uses UTF-8).
69///
70/// This should generally only be used when one specifically wants to support
71/// reading UTF-16 transparently.
72///
73/// If the file path is `-`, then contents are read from stdin instead.
74#[cfg(feature = "tokio")]
75pub async fn read_to_string_transcode(path: impl AsRef<Path>) -> std::io::Result<String> {
76    use std::io::Read;
77
78    use encoding_rs_io::DecodeReaderBytes;
79
80    let path = path.as_ref();
81    let raw = if path == Path::new("-") {
82        let mut buf = Vec::with_capacity(1024);
83        std::io::stdin().read_to_end(&mut buf)?;
84        buf
85    } else {
86        fs_err::tokio::read(path).await?
87    };
88    let mut buf = String::with_capacity(1024);
89    DecodeReaderBytes::new(&*raw)
90        .read_to_string(&mut buf)
91        .map_err(|err| {
92            let path = path.display();
93            std::io::Error::other(format!("failed to decode file {path}: {err}"))
94        })?;
95    Ok(buf)
96}
97
98/// Create a symlink at `dst` pointing to `src`, replacing any existing symlink.
99///
100/// On Windows, this uses the `junction` crate to create a junction point. The
101/// operation is _not_ atomic, as we first delete the junction, then create a
102/// junction at the same path.
103///
104/// Note that because junctions are used, the source must be a directory.
105///
106/// Changes to this function should be reflected in [`create_symlink`].
107#[cfg(windows)]
108pub fn replace_symlink(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Result<()> {
109    // If the source is a file, we can't create a junction
110    if src.as_ref().is_file() {
111        return Err(std::io::Error::new(
112            std::io::ErrorKind::InvalidInput,
113            format!(
114                "Cannot create a junction for {}: is not a directory",
115                src.as_ref().display()
116            ),
117        ));
118    }
119
120    // Remove the existing symlink, if any.
121    match junction::delete(dunce::simplified(dst.as_ref())) {
122        Ok(()) => match fs_err::remove_dir_all(dst.as_ref()) {
123            Ok(()) => {}
124            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
125            Err(err) => return Err(err),
126        },
127        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
128        Err(err) => return Err(err),
129    }
130
131    // Replace it with a new symlink.
132    junction::create(
133        dunce::simplified(src.as_ref()),
134        dunce::simplified(dst.as_ref()),
135    )
136}
137
138/// Create a symlink at `dst` pointing to `src`, replacing any existing symlink if necessary.
139///
140/// On Unix, this method creates a temporary file, then moves it into place.
141#[cfg(unix)]
142pub fn replace_symlink(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Result<()> {
143    // Attempt to create the symlink directly.
144    match fs_err::os::unix::fs::symlink(src.as_ref(), dst.as_ref()) {
145        Ok(()) => Ok(()),
146        Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
147            // Create a symlink, using a temporary file to ensure atomicity.
148            let temp_dir = tempfile::tempdir_in(dst.as_ref().parent().unwrap())?;
149            let temp_file = temp_dir.path().join("link");
150            fs_err::os::unix::fs::symlink(src, &temp_file)?;
151
152            // Move the symlink into the target location.
153            fs_err::rename(&temp_file, dst.as_ref())?;
154
155            Ok(())
156        }
157        Err(err) => Err(err),
158    }
159}
160
161/// Create a symlink at `dst` pointing to `src`.
162///
163/// On Windows, this uses the `junction` crate to create a junction point.
164///
165/// Note that because junctions are used, the source must be a directory.
166///
167/// Changes to this function should be reflected in [`replace_symlink`].
168#[cfg(windows)]
169pub fn create_symlink(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Result<()> {
170    // If the source is a file, we can't create a junction
171    if src.as_ref().is_file() {
172        return Err(std::io::Error::new(
173            std::io::ErrorKind::InvalidInput,
174            format!(
175                "Cannot create a junction for {}: is not a directory",
176                src.as_ref().display()
177            ),
178        ));
179    }
180
181    junction::create(
182        dunce::simplified(src.as_ref()),
183        dunce::simplified(dst.as_ref()),
184    )
185}
186
187/// Create a symlink at `dst` pointing to `src`.
188#[cfg(unix)]
189pub fn create_symlink(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Result<()> {
190    fs_err::os::unix::fs::symlink(src.as_ref(), dst.as_ref())
191}
192
193#[cfg(unix)]
194pub fn remove_symlink(path: impl AsRef<Path>) -> std::io::Result<()> {
195    fs_err::remove_file(path.as_ref())
196}
197
198/// Create a symlink at `dst` pointing to `src` on Unix or copy `src` to `dst` on Windows
199///
200/// This does not replace an existing symlink or file at `dst`.
201///
202/// This does not fallback to copying on Unix.
203///
204/// This function should only be used for files. If targeting a directory, use [`replace_symlink`]
205/// instead; it will use a junction on Windows, which is more performant.
206pub fn symlink_or_copy_file(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Result<()> {
207    #[cfg(windows)]
208    {
209        fs_err::copy(src.as_ref(), dst.as_ref())?;
210    }
211    #[cfg(unix)]
212    {
213        fs_err::os::unix::fs::symlink(src.as_ref(), dst.as_ref())?;
214    }
215
216    Ok(())
217}
218
219#[cfg(windows)]
220pub fn remove_symlink(path: impl AsRef<Path>) -> std::io::Result<()> {
221    match junction::delete(dunce::simplified(path.as_ref())) {
222        Ok(()) => match fs_err::remove_dir_all(path.as_ref()) {
223            Ok(()) => Ok(()),
224            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
225            Err(err) => Err(err),
226        },
227        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
228        Err(err) => Err(err),
229    }
230}
231
232/// Return a [`NamedTempFile`] in the specified directory.
233///
234/// Sets the permissions of the temporary file to `0o666`, to match the non-temporary file default.
235/// ([`NamedTempfile`] defaults to `0o600`.)
236#[cfg(unix)]
237pub fn tempfile_in(path: &Path) -> std::io::Result<NamedTempFile> {
238    use std::os::unix::fs::PermissionsExt;
239    tempfile::Builder::new()
240        .permissions(std::fs::Permissions::from_mode(0o666))
241        .tempfile_in(path)
242}
243
244/// Return a [`NamedTempFile`] in the specified directory.
245#[cfg(not(unix))]
246pub fn tempfile_in(path: &Path) -> std::io::Result<NamedTempFile> {
247    tempfile::Builder::new().tempfile_in(path)
248}
249
250/// Write `data` to `path` atomically using a temporary file and atomic rename.
251#[cfg(feature = "tokio")]
252pub async fn write_atomic(path: impl AsRef<Path>, data: impl AsRef<[u8]>) -> std::io::Result<()> {
253    let temp_file = tempfile_in(
254        path.as_ref()
255            .parent()
256            .expect("Write path must have a parent"),
257    )?;
258    fs_err::tokio::write(&temp_file, &data).await?;
259    persist_with_retry(temp_file, path.as_ref()).await
260}
261
262/// Write `data` to `path` atomically using a temporary file and atomic rename.
263pub fn write_atomic_sync(path: impl AsRef<Path>, data: impl AsRef<[u8]>) -> std::io::Result<()> {
264    let temp_file = tempfile_in(
265        path.as_ref()
266            .parent()
267            .expect("Write path must have a parent"),
268    )?;
269    fs_err::write(&temp_file, &data)?;
270    persist_with_retry_sync(temp_file, path.as_ref())
271}
272
273/// Copy `from` to `to` atomically using a temporary file and atomic rename.
274pub fn copy_atomic_sync(from: impl AsRef<Path>, to: impl AsRef<Path>) -> std::io::Result<()> {
275    let temp_file = tempfile_in(to.as_ref().parent().expect("Write path must have a parent"))?;
276    fs_err::copy(from.as_ref(), &temp_file)?;
277    persist_with_retry_sync(temp_file, to.as_ref())
278}
279
280#[cfg(windows)]
281fn backoff_file_move() -> backon::ExponentialBackoff {
282    use backon::BackoffBuilder;
283    // This amounts to 10 total seconds of trying the operation.
284    // We start at 10 milliseconds and try 9 times, doubling each time, so the last try will take
285    // about 10*(2^9) milliseconds ~= 5 seconds. All other attempts combined should equal
286    // the length of the last attempt (because it's a sum of powers of 2), so 10 seconds overall.
287    backon::ExponentialBuilder::default()
288        .with_min_delay(std::time::Duration::from_millis(10))
289        .with_max_times(9)
290        .build()
291}
292
293/// Rename a file, retrying (on Windows) if it fails due to transient operating system errors.
294#[cfg(feature = "tokio")]
295pub async fn rename_with_retry(
296    from: impl AsRef<Path>,
297    to: impl AsRef<Path>,
298) -> Result<(), std::io::Error> {
299    #[cfg(windows)]
300    {
301        use backon::Retryable;
302        // On Windows, antivirus software can lock files temporarily, making them inaccessible.
303        // This is most common for DLLs, and the common suggestion is to retry the operation with
304        // some backoff.
305        //
306        // See: <https://github.com/astral-sh/uv/issues/1491> & <https://github.com/astral-sh/uv/issues/9531>
307        let from = from.as_ref();
308        let to = to.as_ref();
309
310        let rename = async || fs_err::rename(from, to);
311
312        rename
313            .retry(backoff_file_move())
314            .sleep(tokio::time::sleep)
315            .when(|e| e.kind() == std::io::ErrorKind::PermissionDenied)
316            .notify(|err, _dur| {
317                warn!(
318                    "Retrying rename from {} to {} due to transient error: {}",
319                    from.display(),
320                    to.display(),
321                    err
322                );
323            })
324            .await
325    }
326    #[cfg(not(windows))]
327    {
328        fs_err::tokio::rename(from, to).await
329    }
330}
331
332/// Rename or copy a file, retrying (on Windows) if it fails due to transient operating system
333/// errors, in a synchronous context.
334#[cfg_attr(not(windows), allow(unused_variables))]
335pub fn with_retry_sync(
336    from: impl AsRef<Path>,
337    to: impl AsRef<Path>,
338    operation_name: &str,
339    operation: impl Fn() -> Result<(), std::io::Error>,
340) -> Result<(), std::io::Error> {
341    #[cfg(windows)]
342    {
343        use backon::BlockingRetryable;
344        // On Windows, antivirus software can lock files temporarily, making them inaccessible.
345        // This is most common for DLLs, and the common suggestion is to retry the operation with
346        // some backoff.
347        //
348        // See: <https://github.com/astral-sh/uv/issues/1491> & <https://github.com/astral-sh/uv/issues/9531>
349        let from = from.as_ref();
350        let to = to.as_ref();
351
352        operation
353            .retry(backoff_file_move())
354            .sleep(std::thread::sleep)
355            .when(|err| err.kind() == std::io::ErrorKind::PermissionDenied)
356            .notify(|err, _dur| {
357                warn!(
358                    "Retrying {} from {} to {} due to transient error: {}",
359                    operation_name,
360                    from.display(),
361                    to.display(),
362                    err
363                );
364            })
365            .call()
366            .map_err(|err| {
367                std::io::Error::other(format!(
368                    "Failed {} {} to {}: {}",
369                    operation_name,
370                    from.display(),
371                    to.display(),
372                    err
373                ))
374            })
375    }
376    #[cfg(not(windows))]
377    {
378        operation()
379    }
380}
381
382/// Why a file persist failed
383#[cfg(windows)]
384enum PersistRetryError {
385    /// Something went wrong while persisting, maybe retry (contains error message)
386    Persist(String),
387    /// Something went wrong trying to retrieve the file to persist, we must bail
388    LostState,
389}
390
391/// Persist a `NamedTempFile`, retrying (on Windows) if it fails due to transient operating system errors, in a synchronous context.
392pub async fn persist_with_retry(
393    from: NamedTempFile,
394    to: impl AsRef<Path>,
395) -> Result<(), std::io::Error> {
396    #[cfg(windows)]
397    {
398        use backon::Retryable;
399        // On Windows, antivirus software can lock files temporarily, making them inaccessible.
400        // This is most common for DLLs, and the common suggestion is to retry the operation with
401        // some backoff.
402        //
403        // See: <https://github.com/astral-sh/uv/issues/1491> & <https://github.com/astral-sh/uv/issues/9531>
404        let to = to.as_ref();
405
406        // Ok there's a lot of complex ownership stuff going on here.
407        //
408        // the `NamedTempFile` `persist` method consumes `self`, and returns it back inside
409        // the Error in case of `PersistError`:
410        // https://docs.rs/tempfile/latest/tempfile/struct.NamedTempFile.html#method.persist
411        // So every time we fail, we need to reset the `NamedTempFile` to try again.
412        //
413        // Every time we (re)try we call this outer closure (`let persist = ...`), so it needs to
414        // be at least a `FnMut` (as opposed to `Fnonce`). However the closure needs to return a
415        // totally owned `Future` (so effectively it returns a `FnOnce`).
416        //
417        // But if the `Future` is totally owned it *necessarily* can't write back the `NamedTempFile`
418        // to somewhere the outer `FnMut` can see using references. So we need to use `Arc`s
419        // with interior mutability (`Mutex`) to have the closure and all the Futures it creates share
420        // a single memory location that the `NamedTempFile` can be shuttled in and out of.
421        //
422        // In spite of the Mutex all of this code will run logically serially, so there shouldn't be a
423        // chance for a race where we try to get the `NamedTempFile` but it's actually None. The code
424        // is just written pedantically/robustly.
425        let from = std::sync::Arc::new(std::sync::Mutex::new(Some(from)));
426        let persist = || {
427            // Turn our by-ref-captured Arc into an owned Arc that the Future can capture by-value
428            let from2 = from.clone();
429
430            async move {
431                let maybe_file: Option<NamedTempFile> = from2
432                    .lock()
433                    .map_err(|_| PersistRetryError::LostState)?
434                    .take();
435                if let Some(file) = maybe_file {
436                    file.persist(to).map_err(|err| {
437                        let error_message: String = err.to_string();
438                        // Set back the `NamedTempFile` returned back by the Error
439                        if let Ok(mut guard) = from2.lock() {
440                            *guard = Some(err.file);
441                            PersistRetryError::Persist(error_message)
442                        } else {
443                            PersistRetryError::LostState
444                        }
445                    })
446                } else {
447                    Err(PersistRetryError::LostState)
448                }
449            }
450        };
451
452        let persisted = persist
453            .retry(backoff_file_move())
454            .sleep(tokio::time::sleep)
455            .when(|err| matches!(err, PersistRetryError::Persist(_)))
456            .notify(|err, _dur| {
457                if let PersistRetryError::Persist(error_message) = err {
458                    warn!(
459                        "Retrying to persist temporary file to {}: {}",
460                        to.display(),
461                        error_message,
462                    );
463                }
464            })
465            .await;
466
467        match persisted {
468            Ok(_) => Ok(()),
469            Err(PersistRetryError::Persist(error_message)) => Err(std::io::Error::other(format!(
470                "Failed to persist temporary file to {}: {}",
471                to.display(),
472                error_message,
473            ))),
474            Err(PersistRetryError::LostState) => Err(std::io::Error::other(format!(
475                "Failed to retrieve temporary file while trying to persist to {}",
476                to.display()
477            ))),
478        }
479    }
480    #[cfg(not(windows))]
481    {
482        async { fs_err::rename(from, to) }.await
483    }
484}
485
486/// Persist a `NamedTempFile`, retrying (on Windows) if it fails due to transient operating system errors, in a synchronous context.
487pub fn persist_with_retry_sync(
488    from: NamedTempFile,
489    to: impl AsRef<Path>,
490) -> Result<(), std::io::Error> {
491    #[cfg(windows)]
492    {
493        use backon::BlockingRetryable;
494        // On Windows, antivirus software can lock files temporarily, making them inaccessible.
495        // This is most common for DLLs, and the common suggestion is to retry the operation with
496        // some backoff.
497        //
498        // See: <https://github.com/astral-sh/uv/issues/1491> & <https://github.com/astral-sh/uv/issues/9531>
499        let to = to.as_ref();
500
501        // the `NamedTempFile` `persist` method consumes `self`, and returns it back inside the Error in case of `PersistError`
502        // https://docs.rs/tempfile/latest/tempfile/struct.NamedTempFile.html#method.persist
503        // So we will update the `from` optional value in safe and borrow-checker friendly way every retry
504        // Allows us to use the NamedTempFile inside a FnMut closure used for backoff::retry
505        let mut from = Some(from);
506        let persist = || {
507            // Needed because we cannot move out of `from`, a captured variable in an `FnMut` closure, and then pass it to the async move block
508            if let Some(file) = from.take() {
509                file.persist(to).map_err(|err| {
510                    let error_message = err.to_string();
511                    // Set back the NamedTempFile returned back by the Error
512                    from = Some(err.file);
513                    PersistRetryError::Persist(error_message)
514                })
515            } else {
516                Err(PersistRetryError::LostState)
517            }
518        };
519
520        let persisted = persist
521            .retry(backoff_file_move())
522            .sleep(std::thread::sleep)
523            .when(|err| matches!(err, PersistRetryError::Persist(_)))
524            .notify(|err, _dur| {
525                if let PersistRetryError::Persist(error_message) = err {
526                    warn!(
527                        "Retrying to persist temporary file to {}: {}",
528                        to.display(),
529                        error_message,
530                    );
531                }
532            })
533            .call();
534
535        match persisted {
536            Ok(_) => Ok(()),
537            Err(PersistRetryError::Persist(error_message)) => Err(std::io::Error::other(format!(
538                "Failed to persist temporary file to {}: {}",
539                to.display(),
540                error_message,
541            ))),
542            Err(PersistRetryError::LostState) => Err(std::io::Error::other(format!(
543                "Failed to retrieve temporary file while trying to persist to {}",
544                to.display()
545            ))),
546        }
547    }
548    #[cfg(not(windows))]
549    {
550        fs_err::rename(from, to)
551    }
552}
553
554/// Iterate over the subdirectories of a directory.
555///
556/// If the directory does not exist, returns an empty iterator.
557pub fn directories(
558    path: impl AsRef<Path>,
559) -> Result<impl Iterator<Item = PathBuf>, std::io::Error> {
560    let entries = match path.as_ref().read_dir() {
561        Ok(entries) => Some(entries),
562        Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
563        Err(err) => return Err(err),
564    };
565    Ok(entries
566        .into_iter()
567        .flatten()
568        .filter_map(|entry| match entry {
569            Ok(entry) => Some(entry),
570            Err(err) => {
571                warn!("Failed to read entry: {err}");
572                None
573            }
574        })
575        .filter(|entry| entry.file_type().is_ok_and(|file_type| file_type.is_dir()))
576        .map(|entry| entry.path()))
577}
578
579/// Iterate over the entries in a directory.
580///
581/// If the directory does not exist, returns an empty iterator.
582pub fn entries(path: impl AsRef<Path>) -> Result<impl Iterator<Item = PathBuf>, std::io::Error> {
583    let entries = match path.as_ref().read_dir() {
584        Ok(entries) => Some(entries),
585        Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
586        Err(err) => return Err(err),
587    };
588    Ok(entries
589        .into_iter()
590        .flatten()
591        .filter_map(|entry| match entry {
592            Ok(entry) => Some(entry),
593            Err(err) => {
594                warn!("Failed to read entry: {err}");
595                None
596            }
597        })
598        .map(|entry| entry.path()))
599}
600
601/// Iterate over the files in a directory.
602///
603/// If the directory does not exist, returns an empty iterator.
604pub fn files(path: impl AsRef<Path>) -> Result<impl Iterator<Item = PathBuf>, std::io::Error> {
605    let entries = match path.as_ref().read_dir() {
606        Ok(entries) => Some(entries),
607        Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
608        Err(err) => return Err(err),
609    };
610    Ok(entries
611        .into_iter()
612        .flatten()
613        .filter_map(|entry| match entry {
614            Ok(entry) => Some(entry),
615            Err(err) => {
616                warn!("Failed to read entry: {err}");
617                None
618            }
619        })
620        .filter(|entry| entry.file_type().is_ok_and(|file_type| file_type.is_file()))
621        .map(|entry| entry.path()))
622}
623
624/// Returns `true` if a path is a temporary file or directory.
625pub fn is_temporary(path: impl AsRef<Path>) -> bool {
626    path.as_ref()
627        .file_name()
628        .and_then(|name| name.to_str())
629        .is_some_and(|name| name.starts_with(".tmp"))
630}
631
632/// Checks if the grandparent directory of the given executable is the base
633/// of a virtual environment.
634///
635/// The procedure described in PEP 405 includes checking both the parent and
636/// grandparent directory of an executable, but in practice we've found this to
637/// be unnecessary.
638pub fn is_virtualenv_executable(executable: impl AsRef<Path>) -> bool {
639    executable
640        .as_ref()
641        .parent()
642        .and_then(Path::parent)
643        .is_some_and(is_virtualenv_base)
644}
645
646/// Returns `true` if a path is the base path of a virtual environment,
647/// indicated by the presence of a `pyvenv.cfg` file.
648///
649/// The procedure described in PEP 405 includes scanning `pyvenv.cfg`
650/// for a `home` key, but in practice we've found this to be
651/// unnecessary.
652pub fn is_virtualenv_base(path: impl AsRef<Path>) -> bool {
653    path.as_ref().join("pyvenv.cfg").is_file()
654}
655
656/// Whether the error is due to a lock being held.
657fn is_known_already_locked_error(err: &std::fs::TryLockError) -> bool {
658    match err {
659        std::fs::TryLockError::WouldBlock => true,
660        std::fs::TryLockError::Error(err) => {
661            // On Windows, we've seen: Os { code: 33, kind: Uncategorized, message: "The process cannot access the file because another process has locked a portion of the file." }
662            if cfg!(windows) && err.raw_os_error() == Some(33) {
663                return true;
664            }
665            false
666        }
667    }
668}
669
670/// An asynchronous reader that reports progress as bytes are read.
671#[cfg(feature = "tokio")]
672pub struct ProgressReader<Reader: tokio::io::AsyncRead + Unpin, Callback: Fn(usize) + Unpin> {
673    reader: Reader,
674    callback: Callback,
675}
676
677#[cfg(feature = "tokio")]
678impl<Reader: tokio::io::AsyncRead + Unpin, Callback: Fn(usize) + Unpin>
679    ProgressReader<Reader, Callback>
680{
681    /// Create a new [`ProgressReader`] that wraps another reader.
682    pub fn new(reader: Reader, callback: Callback) -> Self {
683        Self { reader, callback }
684    }
685}
686
687#[cfg(feature = "tokio")]
688impl<Reader: tokio::io::AsyncRead + Unpin, Callback: Fn(usize) + Unpin> tokio::io::AsyncRead
689    for ProgressReader<Reader, Callback>
690{
691    fn poll_read(
692        mut self: std::pin::Pin<&mut Self>,
693        cx: &mut std::task::Context<'_>,
694        buf: &mut tokio::io::ReadBuf<'_>,
695    ) -> std::task::Poll<std::io::Result<()>> {
696        std::pin::Pin::new(&mut self.as_mut().reader)
697            .poll_read(cx, buf)
698            .map_ok(|()| {
699                (self.callback)(buf.filled().len());
700            })
701    }
702}
703
704/// Recursively copy a directory and its contents.
705pub fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Result<()> {
706    fs_err::create_dir_all(&dst)?;
707    for entry in fs_err::read_dir(src.as_ref())? {
708        let entry = entry?;
709        let ty = entry.file_type()?;
710        if ty.is_dir() {
711            copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?;
712        } else {
713            fs_err::copy(entry.path(), dst.as_ref().join(entry.file_name()))?;
714        }
715    }
716    Ok(())
717}
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722    use std::path::PathBuf;
723
724    #[test]
725    fn test_with_added_extension() {
726        // Test with simple package name (no dots)
727        let path = PathBuf::from("python");
728        let result = with_added_extension(&path, "exe");
729        assert_eq!(result, PathBuf::from("python.exe"));
730
731        // Test with package name containing single dot
732        let path = PathBuf::from("awslabs.cdk-mcp-server");
733        let result = with_added_extension(&path, "exe");
734        assert_eq!(result, PathBuf::from("awslabs.cdk-mcp-server.exe"));
735
736        // Test with package name containing multiple dots
737        let path = PathBuf::from("org.example.tool");
738        let result = with_added_extension(&path, "exe");
739        assert_eq!(result, PathBuf::from("org.example.tool.exe"));
740
741        // Test with different extensions
742        let path = PathBuf::from("script");
743        let result = with_added_extension(&path, "ps1");
744        assert_eq!(result, PathBuf::from("script.ps1"));
745
746        // Test with path that has directory components
747        let path = PathBuf::from("some/path/to/awslabs.cdk-mcp-server");
748        let result = with_added_extension(&path, "exe");
749        assert_eq!(
750            result,
751            PathBuf::from("some/path/to/awslabs.cdk-mcp-server.exe")
752        );
753
754        // Test with empty path (edge case)
755        let path = PathBuf::new();
756        let result = with_added_extension(&path, "exe");
757        assert_eq!(result, path); // Should return unchanged
758    }
759}