shuck-cli 0.0.42

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

use anyhow::{Context, Result};
use serde::Deserialize;
use shuck_config::ConfigArguments;
use tempfile::tempdir;

use super::run::run_check_with_cwd;
use super::test_support::{cache_root, check_args};
use super::watch::collect_watch_targets;
use crate::discover::normalize_path;

#[derive(Debug, Deserialize)]
struct Fixture {
    name: String,
    path: String,
    #[serde(default)]
    files: Vec<FixtureFile>,
    #[serde(default)]
    after_files: Vec<FixtureFile>,
}

#[derive(Debug, Deserialize)]
struct FixtureFile {
    path: String,
    #[serde(default)]
    contents: String,
}

#[test]
fn zsh_plugin_dependency_fixtures_record_missing_paths_and_invalidate_cache() -> Result<()> {
    for fixture_path in fixture_paths()? {
        let fixture = load_fixture(&fixture_path)?;
        let tempdir = tempdir().context("failed to create temp dir")?;
        materialize_files(tempdir.path(), &fixture.files)?;

        let expected_dependencies = fixture_dependency_paths(tempdir.path(), &fixture);
        let mut args = check_args(false);
        args.paths = vec![PathBuf::from(&fixture.path)];

        let first = run_check_with_cwd(
            &args,
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .with_context(|| format!("fixture `{}` initial run failed", fixture.name))?;
        assert_eq!(
            first.cache_hits, 0,
            "fixture `{}` should miss cache on first run",
            fixture.name
        );
        assert_eq!(
            first.cache_misses, 1,
            "fixture `{}` should analyze its input on first run",
            fixture.name
        );
        assert_dependency_subset(
            &fixture.name,
            "initial run",
            &first.dependency_paths,
            &expected_dependencies,
        );

        let second = run_check_with_cwd(
            &args,
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .with_context(|| format!("fixture `{}` cached rerun failed", fixture.name))?;
        assert_eq!(
            second.cache_hits, 1,
            "fixture `{}` should hit cache before dependencies change",
            fixture.name
        );
        assert_eq!(
            second.cache_misses, 0,
            "fixture `{}` should not reanalyze before dependencies change",
            fixture.name
        );
        assert_dependency_subset(
            &fixture.name,
            "cached rerun",
            &second.dependency_paths,
            &expected_dependencies,
        );

        materialize_files(tempdir.path(), &fixture.after_files)?;

        let third = run_check_with_cwd(
            &args,
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .with_context(|| {
            format!(
                "fixture `{}` rerun after dependency creation failed",
                fixture.name
            )
        })?;
        assert_eq!(
            third.cache_hits, 0,
            "fixture `{}` should invalidate its cache entry when a dependency appears",
            fixture.name
        );
        assert_eq!(
            third.cache_misses, 1,
            "fixture `{}` should reanalyze after dependency creation",
            fixture.name
        );
        assert_dependency_subset(
            &fixture.name,
            "rerun after dependency creation",
            &third.dependency_paths,
            &expected_dependencies,
        );
    }

    Ok(())
}

#[test]
fn zsh_plugin_dependency_fixtures_build_watch_targets_for_missing_dependencies() -> Result<()> {
    for fixture_path in fixture_paths()? {
        let fixture = load_fixture(&fixture_path)?;
        let tempdir = tempdir().context("failed to create temp dir")?;
        materialize_files(tempdir.path(), &fixture.files)?;

        let expected_dependencies = fixture_dependency_paths(tempdir.path(), &fixture);
        let mut args = check_args(false);
        args.paths = vec![PathBuf::from(&fixture.path)];

        let report = run_check_with_cwd(
            &args,
            &ConfigArguments::default(),
            tempdir.path(),
            &cache_root(tempdir.path()),
        )
        .with_context(|| format!("fixture `{}` initial run failed", fixture.name))?;
        let targets = collect_watch_targets(
            &args.paths,
            &ConfigArguments::default(),
            tempdir.path(),
            &report.dependency_paths,
        )
        .with_context(|| format!("fixture `{}` failed to collect watch targets", fixture.name))?;

        for dependency in expected_dependencies {
            let target = targets
                .iter()
                .find(|target| target.match_paths.contains(&dependency))
                .unwrap_or_else(|| {
                    panic!(
                        "fixture `{}` did not build a watch target for missing dependency {}",
                        fixture.name,
                        dependency.display()
                    )
                });

            let parent_exists = dependency.parent().is_some_and(Path::exists);
            assert_eq!(
                target.recursive,
                !parent_exists,
                "fixture `{}` should use recursive watch mode when dependency parent {} is missing",
                fixture.name,
                dependency.parent().unwrap_or(&dependency).display()
            );
        }
    }

    Ok(())
}

fn fixture_dir() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("testdata")
        .join("zsh-plugin-dependencies")
}

fn fixture_paths() -> Result<Vec<PathBuf>> {
    let mut paths = fs::read_dir(fixture_dir())
        .with_context(|| format!("failed to read {}", fixture_dir().display()))?
        .map(|entry| entry.map(|entry| entry.path()))
        .collect::<Result<Vec<_>, _>>()
        .context("failed to enumerate zsh plugin dependency fixtures")?;
    paths.retain(|path| path.extension().and_then(|ext| ext.to_str()) == Some("toml"));
    paths.sort();
    Ok(paths)
}

fn load_fixture(path: &Path) -> Result<Fixture> {
    let text =
        fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
    toml::from_str(&text).with_context(|| format!("failed to parse {}", path.display()))
}

fn materialize_files(root: &Path, files: &[FixtureFile]) -> Result<()> {
    for file in files {
        let path = root.join(&file.path);
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)
                .with_context(|| format!("failed to create {}", parent.display()))?;
        }
        fs::write(&path, &file.contents)
            .with_context(|| format!("failed to write {}", path.display()))?;
    }
    Ok(())
}

fn fixture_dependency_paths(root: &Path, fixture: &Fixture) -> Vec<PathBuf> {
    let root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
    fixture
        .after_files
        .iter()
        .map(|file| normalize_path(&root.join(&file.path)))
        .collect()
}

fn assert_dependency_subset(
    fixture_name: &str,
    stage: &str,
    actual: &[PathBuf],
    expected: &[PathBuf],
) {
    for dependency in expected {
        assert!(
            actual.contains(dependency),
            "fixture `{fixture_name}` {stage} did not record dependency {}\nactual: {:?}",
            dependency.display(),
            actual
        );
    }
}