shuck-cli 0.0.17

A fast shell script linter
Documentation
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use anyhow::Result;
use serde::{Serialize, de::DeserializeOwned};
use shuck_cache::{CacheKey, CacheKeyHasher, FileCacheKey, PackageCache};

use crate::discover::{DiscoveredFile, DiscoveryOptions, ProjectRoot, discover_files};

pub(crate) struct ProjectRun<T, S>
where
    T: Clone + Serialize + DeserializeOwned,
{
    #[allow(dead_code)]
    pub project_root: ProjectRoot,
    pub files: Vec<DiscoveredFile>,
    pub settings: S,
    pub cache: Option<PackageCache<T>>,
}

#[derive(Debug, Clone)]
pub(crate) struct PendingProjectFile {
    pub file: DiscoveredFile,
    pub file_key: FileCacheKey,
}

#[derive(Debug, Clone)]
struct ProjectCacheKey<S> {
    cache_tag: &'static [u8],
    canonical_project_root: PathBuf,
    settings: S,
}

impl<S> CacheKey for ProjectCacheKey<S>
where
    S: CacheKey,
{
    fn cache_key(&self, state: &mut CacheKeyHasher) {
        state.write_tag(self.cache_tag);
        self.canonical_project_root.cache_key(state);
        self.settings.cache_key(state);
    }
}

pub(crate) fn prepare_project_runs<T, S, F>(
    inputs: &[PathBuf],
    cwd: &Path,
    discovery_options: &DiscoveryOptions,
    cache_root: &Path,
    no_cache: bool,
    cache_tag: &'static [u8],
    mut resolve_settings: F,
) -> Result<Vec<ProjectRun<T, S>>>
where
    T: Clone + Serialize + DeserializeOwned,
    S: CacheKey + Clone,
    F: FnMut(&ProjectRoot) -> Result<S>,
{
    let files = discover_files(inputs, cwd, discovery_options)?;
    let mut groups: BTreeMap<ProjectRoot, Vec<DiscoveredFile>> = BTreeMap::new();
    for file in files {
        groups
            .entry(file.project_root.clone())
            .or_default()
            .push(file);
    }

    let mut runs = Vec::new();
    for (project_root, files) in groups {
        let settings = resolve_settings(&project_root)?;
        let cache = if no_cache {
            None
        } else {
            Some(PackageCache::<T>::open(
                cache_root,
                project_root.canonical_root.clone(),
                env!("CARGO_PKG_VERSION"),
                &ProjectCacheKey {
                    cache_tag,
                    canonical_project_root: project_root.canonical_root.clone(),
                    settings: settings.clone(),
                },
            )?)
        };

        runs.push(ProjectRun {
            project_root,
            files,
            settings,
            cache,
        });
    }

    Ok(runs)
}

impl<T, S> ProjectRun<T, S>
where
    T: Clone + Serialize + DeserializeOwned,
{
    pub(crate) fn take_pending_files<F>(&mut self, mut on_hit: F) -> Result<Vec<PendingProjectFile>>
    where
        F: FnMut(DiscoveredFile, T) -> Result<()>,
    {
        let mut pending = Vec::new();

        for file in self.files.drain(..) {
            let file_key = FileCacheKey::from_path(&file.absolute_path)?;
            if let Some(cache) = self.cache.as_mut()
                && let Some(cached) = cache.get(&file.relative_path, &file_key)
            {
                on_hit(file, cached)?;
                continue;
            }

            pending.push(PendingProjectFile { file, file_key });
        }

        Ok(pending)
    }

    pub(crate) fn persist_cache(self) -> Result<()> {
        if let Some(cache) = self.cache {
            cache.persist()?;
        }

        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use std::fs;

    use serde::{Deserialize, Serialize};
    use tempfile::tempdir;

    use super::*;

    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    struct DummyCacheData {
        name: String,
    }

    #[derive(Debug, Clone, PartialEq, Eq)]
    struct DummySettings;

    impl CacheKey for DummySettings {
        fn cache_key(&self, state: &mut CacheKeyHasher) {
            state.write_tag(b"dummy-settings");
        }
    }

    fn cache_root(root: &Path) -> PathBuf {
        root.join("cache")
    }

    fn discovery_options(cache_root: &Path) -> DiscoveryOptions {
        DiscoveryOptions {
            parallel: false,
            cache_root: Some(cache_root.to_path_buf()),
            ..DiscoveryOptions::default()
        }
    }

    #[test]
    fn groups_discovered_files_by_project_root() {
        let tempdir = tempdir().unwrap();
        let nested = tempdir.path().join("nested");
        fs::create_dir_all(&nested).unwrap();
        fs::write(tempdir.path().join("shuck.toml"), "[format]\n").unwrap();
        fs::write(nested.join("shuck.toml"), "[format]\n").unwrap();
        fs::write(tempdir.path().join("root.sh"), "#!/bin/bash\necho root\n").unwrap();
        fs::write(nested.join("nested.sh"), "#!/bin/bash\necho nested\n").unwrap();

        let runs = prepare_project_runs::<DummyCacheData, DummySettings, _>(
            &[tempdir.path().to_path_buf()],
            tempdir.path(),
            &discovery_options(&cache_root(tempdir.path())),
            &cache_root(tempdir.path()),
            true,
            b"dummy-cache",
            |_| Ok(DummySettings),
        )
        .unwrap();

        assert_eq!(runs.len(), 2);
        let mut display_paths = runs
            .iter()
            .map(|run| run.files[0].display_path.clone())
            .collect::<Vec<_>>();
        display_paths.sort();
        assert_eq!(
            display_paths,
            vec![PathBuf::from("nested/nested.sh"), PathBuf::from("root.sh")]
        );
    }

    #[test]
    fn cached_hits_are_reported_and_uncached_files_stay_pending() {
        let tempdir = tempdir().unwrap();
        fs::write(tempdir.path().join("one.sh"), "#!/bin/bash\necho one\n").unwrap();
        fs::write(tempdir.path().join("two.sh"), "#!/bin/bash\necho two\n").unwrap();

        let cache_root = cache_root(tempdir.path());
        let mut runs = prepare_project_runs::<DummyCacheData, DummySettings, _>(
            &[tempdir.path().to_path_buf()],
            tempdir.path(),
            &discovery_options(&cache_root),
            &cache_root,
            false,
            b"dummy-cache",
            |_| Ok(DummySettings),
        )
        .unwrap();
        let mut run = runs.pop().unwrap();
        let mut pending = run.take_pending_files(|_, _| unreachable!()).unwrap();
        assert_eq!(pending.len(), 2);

        let cached = pending.remove(0);
        run.cache.as_mut().unwrap().insert(
            cached.file.relative_path.clone(),
            cached.file_key.clone(),
            DummyCacheData {
                name: cached.file.display_path.display().to_string(),
            },
        );
        run.persist_cache().unwrap();

        let mut runs = prepare_project_runs::<DummyCacheData, DummySettings, _>(
            &[tempdir.path().to_path_buf()],
            tempdir.path(),
            &discovery_options(&cache_root),
            &cache_root,
            false,
            b"dummy-cache",
            |_| Ok(DummySettings),
        )
        .unwrap();
        let mut run = runs.pop().unwrap();

        let mut cached_hits = Vec::new();
        let pending = run
            .take_pending_files(|file, cached| {
                cached_hits.push((file.display_path, cached.name));
                Ok(())
            })
            .unwrap();

        assert_eq!(cached_hits.len(), 1);
        assert_eq!(pending.len(), 1);
    }
}