wit_deps/
lib.rs

1//! WIT dependency management core library
2
3#![forbid(clippy::unwrap_used)]
4#![warn(missing_docs)]
5
6mod cache;
7mod digest;
8mod lock;
9mod manifest;
10
11pub use cache::{Cache, Local as LocalCache, Write as WriteCache};
12pub use digest::{Digest, Reader as DigestReader, Writer as DigestWriter};
13pub use lock::{Entry as LockEntry, EntrySource as LockEntrySource, Lock};
14pub use manifest::{Entry as ManifestEntry, Manifest};
15
16pub use futures;
17pub use tokio;
18
19use core::array;
20
21use std::collections::{BTreeSet, HashMap, HashSet};
22use std::ffi::{OsStr, OsString};
23use std::path::{Path, PathBuf};
24
25use anyhow::Context;
26use futures::{try_join, AsyncRead, AsyncWrite, FutureExt, Stream, TryStreamExt};
27use tokio::fs;
28use tokio_stream::wrappers::ReadDirStream;
29use tracing::{debug, instrument, trace};
30
31/// WIT dependency identifier
32pub type Identifier = String;
33// TODO: Introduce a rich type with name validation
34//#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq)]
35//pub struct Identifier(String);
36
37fn is_wit(path: impl AsRef<Path>) -> bool {
38    path.as_ref()
39        .extension()
40        .is_some_and(|ext| ext.eq_ignore_ascii_case("wit"))
41}
42
43#[instrument(level = "trace", skip(path))]
44async fn remove_dir_all(path: impl AsRef<Path>) -> std::io::Result<()> {
45    let path = path.as_ref();
46    match fs::remove_dir_all(path).await {
47        Ok(()) => {
48            trace!("removed `{}`", path.display());
49            Ok(())
50        }
51        Err(e) => Err(std::io::Error::new(
52            e.kind(),
53            format!("failed to remove `{}`: {e}", path.display()),
54        )),
55    }
56}
57
58#[instrument(level = "trace", skip(path))]
59async fn recreate_dir(path: impl AsRef<Path>) -> std::io::Result<()> {
60    let path = path.as_ref();
61    match remove_dir_all(path).await {
62        Ok(()) => {}
63        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
64        Err(e) => return Err(e),
65    }
66    fs::create_dir_all(path)
67        .await
68        .map(|()| trace!("recreated `{}`", path.display()))
69        .map_err(|e| {
70            std::io::Error::new(
71                e.kind(),
72                format!("failed to create `{}`: {e}", path.display()),
73            )
74        })
75}
76
77/// Returns a stream of WIT file names within a directory at `path`
78#[instrument(level = "trace", skip(path))]
79async fn read_wits(
80    path: impl AsRef<Path>,
81) -> std::io::Result<impl Stream<Item = std::io::Result<OsString>>> {
82    let path = path.as_ref();
83    let st = fs::read_dir(path)
84        .await
85        .map(ReadDirStream::new)
86        .map_err(|e| {
87            std::io::Error::new(
88                e.kind(),
89                format!("failed to read directory at `{}`: {e}", path.display()),
90            )
91        })?;
92    Ok(st.try_filter_map(|e| async move {
93        let name = e.file_name();
94        if !is_wit(&name) {
95            trace!("{} is not a WIT definition, skip", name.to_string_lossy());
96            return Ok(None);
97        }
98        if e.file_type().await?.is_dir() {
99            trace!("{} is a directory, skip", name.to_string_lossy());
100            return Ok(None);
101        }
102        Ok(Some(name))
103    }))
104}
105
106/// Copies all WIT definitions from directory at `src` to `dst` creating `dst` directory, if it does not exist.
107#[instrument(level = "trace", skip(src, dst))]
108async fn install_wits(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Result<()> {
109    let src = src.as_ref();
110    let dst = dst.as_ref();
111    recreate_dir(dst).await?;
112    read_wits(src)
113        .await?
114        .try_for_each_concurrent(None, |name| async {
115            let src = src.join(&name);
116            let dst = dst.join(name);
117            fs::copy(&src, &dst)
118                .await
119                .map(|_| trace!("copied `{}` to `{}`", src.display(), dst.display()))
120                .map_err(|e| {
121                    std::io::Error::new(
122                        e.kind(),
123                        format!(
124                            "failed to copy `{}` to `{}`: {e}",
125                            src.display(),
126                            dst.display()
127                        ),
128                    )
129                })
130        })
131        .await
132}
133
134/// Copies all WIT files from directory at `src` to `dst` and returns a vector identifiers of all copied
135/// transitive dependencies.
136#[instrument(level = "trace", skip(src, dst, skip_deps))]
137async fn copy_wits(
138    src: impl AsRef<Path>,
139    dst: impl AsRef<Path>,
140    skip_deps: &HashSet<Identifier>,
141) -> std::io::Result<HashMap<Identifier, PathBuf>> {
142    let src = src.as_ref();
143    let deps = src.join("deps");
144    let dst = dst.as_ref();
145    try_join!(install_wits(src, dst), async {
146        match (dst.parent(), fs::read_dir(&deps).await) {
147            (Some(base), Ok(dir)) => {
148                ReadDirStream::new(dir)
149                    .try_filter_map(|e| async move {
150                        let name = e.file_name();
151                        let Some(id) = name.to_str().map(Identifier::from) else {
152                            return Ok(None);
153                        };
154                        if skip_deps.contains(&id) {
155                            return Ok(None);
156                        }
157                        let ft = e.file_type().await?;
158                        if !(ft.is_dir()
159                            || ft.is_symlink() && fs::metadata(e.path()).await?.is_dir())
160                        {
161                            return Ok(None);
162                        }
163                        Ok(Some(id))
164                    })
165                    .and_then(|id| async {
166                        let dst = base.join(&id);
167                        install_wits(deps.join(&id), &dst).await?;
168                        Ok((id, dst))
169                    })
170                    .try_collect()
171                    .await
172            }
173            (None, _) => Ok(HashMap::default()),
174            (_, Err(e)) if e.kind() == std::io::ErrorKind::NotFound => Ok(HashMap::default()),
175            (_, Err(e)) => Err(std::io::Error::new(
176                e.kind(),
177                format!("failed to read directory at `{}`: {e}", deps.display()),
178            )),
179        }
180    })
181    .map(|((), ids)| ids)
182}
183
184/// Unpacks all WIT interfaces found within `wit` subtree of a tar archive read from `tar` to
185/// `dst` and returns a [`HashMap`] of all unpacked transitive dependency identifiers.
186///
187/// # Errors
188///
189/// Returns and error if the operation fails
190#[instrument(level = "trace", skip(tar, dst, skip_deps))]
191pub async fn untar(
192    tar: impl AsyncRead + Unpin,
193    dst: impl AsRef<Path>,
194    skip_deps: &HashSet<Identifier>,
195    prefix: &str,
196) -> std::io::Result<HashMap<Identifier, PathBuf>> {
197    use std::io::{Error, Result};
198
199    async fn unpack(e: &mut async_tar::Entry<impl Unpin + AsyncRead>, dst: &Path) -> Result<()> {
200        e.unpack(dst).await.map_err(|e| {
201            Error::new(
202                e.kind(),
203                format!("failed to unpack `{}`: {e}", dst.display()),
204            )
205        })?;
206        trace!("unpacked `{}`", dst.display());
207        Ok(())
208    }
209
210    let dst = dst.as_ref();
211    recreate_dir(dst).await?;
212    async_tar::Archive::new(tar)
213        .entries()
214        .map_err(|e| Error::new(e.kind(), format!("failed to unpack archive metadata: {e}")))?
215        .try_fold(HashMap::default(), |mut untared, mut e| async move {
216            let path = e
217                .path()
218                .map_err(|e| Error::new(e.kind(), format!("failed to query entry path: {e}")))?;
219            let Ok(path) = path.strip_prefix(prefix) else {
220                return Ok(untared);
221            };
222            let mut path = path.into_iter();
223            match array::from_fn::<_, 6, _>(|_| path.next().and_then(OsStr::to_str)) {
224                [Some(name), None, ..]
225                | [Some("wit"), Some(name), None, ..]
226                | [Some(_), Some("wit"), Some(name), None, ..]
227                    if is_wit(name) =>
228                {
229                    let dst = dst.join(name);
230                    unpack(&mut e, &dst).await?;
231                    Ok(untared)
232                }
233                [Some("deps"), Some(id), Some(name), None, ..]
234                | [Some("wit"), Some("deps"), Some(id), Some(name), None, ..]
235                | [Some(_), Some("wit"), Some("deps"), Some(id), Some(name), None]
236                    if !skip_deps.contains(id) && is_wit(name) =>
237                {
238                    let id = Identifier::from(id);
239                    if let Some(base) = dst.parent() {
240                        let dst = base.join(&id);
241                        if !untared.contains_key(&id) {
242                            recreate_dir(&dst).await?;
243                        }
244                        let wit = dst.join(name);
245                        unpack(&mut e, &wit).await?;
246                        untared.insert(id, dst);
247                        Ok(untared)
248                    } else {
249                        Ok(untared)
250                    }
251                }
252                _ => Ok(untared),
253            }
254        })
255        .await
256}
257
258/// Packages path into a `wit` subtree in deterministic `tar` archive and writes it to `dst`.
259///
260/// # Errors
261///
262/// Returns and error if the operation fails
263#[instrument(level = "trace", skip(path, dst))]
264pub async fn tar<T>(path: impl AsRef<Path>, dst: T) -> std::io::Result<T>
265where
266    T: AsyncWrite + Sync + Send + Unpin,
267{
268    let path = path.as_ref();
269    let mut tar = async_tar::Builder::new(dst);
270    tar.mode(async_tar::HeaderMode::Deterministic);
271    for name in read_wits(path).await?.try_collect::<BTreeSet<_>>().await? {
272        tar.append_path_with_name(path.join(&name), Path::new("wit").join(name))
273            .await?;
274    }
275    tar.into_inner().await
276}
277
278fn cache() -> Option<impl Cache> {
279    LocalCache::cache_dir().map(|cache| {
280        debug!("using cache at `{cache}`");
281        cache
282    })
283}
284
285/// Given a TOML-encoded manifest and optional TOML-encoded lock, ensures that the path pointed to by
286/// `deps` is in sync with the manifest and lock. This is a potentially destructive operation!
287/// Returns a TOML-encoded lock if the lock passed to this function was either `None` or out-of-sync.
288///
289/// # Errors
290///
291/// Returns an error if anything in the pipeline fails
292#[instrument(level = "trace", skip(at, manifest, lock, deps))]
293pub async fn lock(
294    at: Option<impl AsRef<Path>>,
295    manifest: impl AsRef<str>,
296    lock: Option<impl AsRef<str>>,
297    deps: impl AsRef<Path>,
298) -> anyhow::Result<Option<String>> {
299    let manifest: Manifest =
300        toml::from_str(manifest.as_ref()).context("failed to decode manifest")?;
301
302    let old_lock = lock
303        .as_ref()
304        .map(AsRef::as_ref)
305        .map(toml::from_str)
306        .transpose()
307        .context("failed to decode lock")?;
308
309    let deps = deps.as_ref();
310    let lock = manifest
311        .lock(at, deps, old_lock.as_ref(), cache().as_ref())
312        .await
313        .with_context(|| format!("failed to lock deps to `{}`", deps.display()))?;
314    match old_lock {
315        Some(old_lock) if lock == old_lock => Ok(None),
316        _ => toml::to_string(&lock)
317            .map(Some)
318            .context("failed to encode lock"),
319    }
320}
321
322/// Given a TOML-encoded manifest, ensures that the path pointed to by
323/// `deps` is in sync with the manifest. This is a potentially destructive operation!
324/// Returns a TOML-encoded lock on success.
325///
326/// # Errors
327///
328/// Returns an error if anything in the pipeline fails
329#[instrument(level = "trace", skip(at, manifest, deps))]
330pub async fn update(
331    at: Option<impl AsRef<Path>>,
332    manifest: impl AsRef<str>,
333    deps: impl AsRef<Path>,
334) -> anyhow::Result<String> {
335    let manifest: Manifest =
336        toml::from_str(manifest.as_ref()).context("failed to decode manifest")?;
337
338    let deps = deps.as_ref();
339    let lock = manifest
340        .lock(at, deps, None, cache().map(WriteCache).as_ref())
341        .await
342        .with_context(|| format!("failed to lock deps to `{}`", deps.display()))?;
343    toml::to_string(&lock).context("failed to encode lock")
344}
345
346async fn read_manifest_string(path: impl AsRef<Path>) -> std::io::Result<String> {
347    let path = path.as_ref();
348    fs::read_to_string(&path).await.map_err(|e| {
349        std::io::Error::new(
350            e.kind(),
351            format!("failed to read manifest at `{}`: {e}", path.display()),
352        )
353    })
354}
355
356async fn write_lock(path: impl AsRef<Path>, buf: impl AsRef<[u8]>) -> std::io::Result<()> {
357    let path = path.as_ref();
358    if let Some(parent) = path.parent() {
359        fs::create_dir_all(parent).await.map_err(|e| {
360            std::io::Error::new(
361                e.kind(),
362                format!(
363                    "failed to create lock parent directory `{}`: {e}",
364                    parent.display()
365                ),
366            )
367        })?;
368    }
369    fs::write(&path, &buf).await.map_err(|e| {
370        std::io::Error::new(
371            e.kind(),
372            format!("failed to write lock to `{}`: {e}", path.display()),
373        )
374    })
375}
376
377/// Like [lock](self::lock()), but reads the manifest at `manifest_path` and reads/writes the lock at `lock_path`.
378///
379/// Returns `true` if the lock was updated and `false` otherwise.
380///
381/// # Errors
382///
383/// Returns an error if anything in the pipeline fails
384#[instrument(level = "trace", skip(manifest_path, lock_path, deps))]
385pub async fn lock_path(
386    manifest_path: impl AsRef<Path>,
387    lock_path: impl AsRef<Path>,
388    deps: impl AsRef<Path>,
389) -> anyhow::Result<bool> {
390    let manifest_path = manifest_path.as_ref();
391    let lock_path = lock_path.as_ref();
392    let (manifest, lock) = try_join!(
393        read_manifest_string(manifest_path),
394        fs::read_to_string(&lock_path).map(|res| match res {
395            Ok(lock) => Ok(Some(lock)),
396            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
397            Err(e) => Err(std::io::Error::new(
398                e.kind(),
399                format!("failed to read lock at `{}`: {e}", lock_path.display())
400            )),
401        }),
402    )?;
403    if let Some(lock) = self::lock(manifest_path.parent(), manifest, lock, deps)
404        .await
405        .context("failed to lock dependencies")?
406    {
407        write_lock(lock_path, lock).await?;
408        Ok(true)
409    } else {
410        Ok(false)
411    }
412}
413
414/// Like [update](self::update()), but reads the manifest at `manifest_path` and writes the lock at `lock_path`.
415///
416/// # Errors
417///
418/// Returns an error if anything in the pipeline fails
419#[instrument(level = "trace", skip(manifest_path, lock_path, deps))]
420pub async fn update_path(
421    manifest_path: impl AsRef<Path>,
422    lock_path: impl AsRef<Path>,
423    deps: impl AsRef<Path>,
424) -> anyhow::Result<()> {
425    let manifest_path = manifest_path.as_ref();
426    let manifest = read_manifest_string(manifest_path).await?;
427    let lock = self::update(manifest_path.parent(), manifest, deps)
428        .await
429        .context("failed to lock dependencies")?;
430    write_lock(lock_path, lock).await?;
431    Ok(())
432}
433
434/// Asynchronously ensure dependency manifest, lock and dependencies are in sync.
435/// This must run within a [tokio] context.
436#[macro_export]
437macro_rules! lock {
438    () => {
439        $crate::lock!("wit")
440    };
441    ($dir:literal $(,)?) => {
442        async {
443            use $crate::tokio::fs;
444
445            use std::io::{Error, ErrorKind};
446
447            let lock = match fs::read_to_string(concat!($dir, "/deps.lock")).await {
448                Ok(lock) => Some(lock),
449                Err(e) if e.kind() == ErrorKind::NotFound => None,
450                Err(e) => {
451                    return Err(Error::new(
452                        e.kind(),
453                        format!(
454                            "failed to read lock at `{}`: {e}",
455                            concat!($dir, "/deps.lock")
456                        ),
457                    ))
458                }
459            };
460            match $crate::lock(
461                Some($dir),
462                include_str!(concat!($dir, "/deps.toml")),
463                lock,
464                concat!($dir, "/deps"),
465            )
466            .await
467            {
468                Ok(Some(lock)) => fs::write(concat!($dir, "/deps.lock"), lock)
469                    .await
470                    .map_err(|e| {
471                        Error::new(
472                            e.kind(),
473                            format!(
474                                "failed to write lock at `{}`: {e}",
475                                concat!($dir, "/deps.lock")
476                            ),
477                        )
478                    }),
479                Ok(None) => Ok(()),
480                Err(e) => Err(Error::new(ErrorKind::Other, e)),
481            }
482        }
483    };
484}
485
486#[cfg(feature = "sync")]
487/// Synchronously ensure dependency manifest, lock and dependencies are in sync.
488#[macro_export]
489macro_rules! lock_sync {
490    ($($args:tt)*) => {
491        $crate::tokio::runtime::Builder::new_multi_thread()
492            .thread_name("wit-deps/lock_sync")
493            .enable_io()
494            .enable_time()
495            .build()?
496            .block_on($crate::lock!($($args)*))
497    };
498}