wsb 0.253.6349

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! Scanning the workspace into the catalogue table.
//!
//! One pass over every repository the superproject contains — itself and each
//! submodule, recursively — writing what it finds as rows. There were two
//! implementations of this before, a Rust one and a Python one, and neither
//! wrote anything down: each printed a markdown file that a person then had to
//! read, and thirty-one of those files were maintained by hand across the tree.
//! A number×repository matrix, a duplicate, a misnumbering — those are questions
//! about a set, and a set is a table.

use std::collections::BTreeSet;
use std::path::{Path, PathBuf};

use crate::persist::local::models::catalogued_test::{CataloguedTest, TestLanguage};
use crate::persist::local::repos::catalogue_repo::CatalogueRepo;
use crate::catalogue::{scan_tree, ProjectType, TestInfo};

/// What one repository's scan found.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoScan {
    /// Relative to the workspace root; the superproject itself is `""`.
    pub repo: String,
    pub found: usize,
    pub numbered: usize,
}

/// Every repository under `root`: the superproject, then each submodule, in
/// path order and recursively.
///
/// Read from `.gitmodules` rather than by walking for `.git` directories,
/// because that is where the set is declared. A checkout that has not been
/// initialised has no `.git` inside it and would silently drop out of the
/// catalogue; declared, it stays in the list and fails to scan visibly.
pub fn repositories(root: &Path) -> Vec<PathBuf> {
    let mut out = vec![root.to_path_buf()];
    let mut queue = vec![root.to_path_buf()];
    while let Some(current) = queue.pop() {
        let Ok(modules) = crate::fleet::config::GitModules::parse(&current) else {
            continue;
        };
        for submodule in modules.submodules {
            let path = current.join(&submodule.path);
            if !path.is_dir() {
                continue;
            }
            out.push(path.clone());
            queue.push(path);
        }
    }
    out.sort();
    out.dedup();
    out
}

/// The paths, relative to `parent`, of the repositories nested inside it.
///
/// Every repository's scan must stop at its own submodules, or a test is
/// catalogued once for each repository that contains it — the superproject, the
/// family parent, and the mirror. That is how `capdag` reported 5,209 tests it
/// does not have: the sum of its five members, counted a second time.
fn nested_within(parent: &Path, all: &[PathBuf]) -> BTreeSet<PathBuf> {
    all.iter()
        .filter(|path| path.as_path() != parent)
        .filter_map(|path| path.strip_prefix(parent).ok())
        .map(Path::to_path_buf)
        .collect()
}

fn language_for(info: &TestInfo, language: ProjectType) -> TestLanguage {
    match language {
        ProjectType::Rust => TestLanguage::Rust,
        ProjectType::Go => TestLanguage::Go,
        ProjectType::Python => TestLanguage::Python,
        ProjectType::Js => TestLanguage::Js,
        ProjectType::Shell => TestLanguage::Shell,
        ProjectType::Swift => {
            if info.file_path.extension().is_some_and(|e| e == "m") {
                TestLanguage::Objc
            } else {
                TestLanguage::Swift
            }
        }
    }
}

/// Turn what the scanner found into rows, with paths relative to the repo.
fn rows_for(repo_root: &Path, repo: &str, found: Vec<TestInfo>) -> Result<Vec<CataloguedTest>, String> {
    let mut out = Vec::with_capacity(found.len());
    for info in found {
        let relative = info.file_path.strip_prefix(repo_root).map_err(|_| {
            format!(
                "{} is outside the repository {} it was found in",
                info.file_path.display(),
                repo_root.display()
            )
        })?;
        let language = crate::catalogue::language_of_path(&info.file_path).ok_or_else(
            || {
                format!(
                    "{} produced a test but names no language",
                    info.file_path.display()
                )
            },
        )?;
        let number = match &info.number {
            // The leading zeros in `test001_` are formatting; the number is the
            // number, and storing it as text would sort 1000 before 999.
            Some(text) => Some(text.parse::<i64>().map_err(|e| {
                format!("{} claims a number that is not one: {text} ({e})", info.function_name)
            })?),
            None => None,
        };
        out.push(CataloguedTest {
            id: uuid::Uuid::new_v4().to_string(),
            repo: repo.to_string(),
            language: language_for(&info, language),
            number,
            function_name: info.function_name,
            description: info.description,
            file_path: relative.display().to_string(),
            line_number: info.line_number as i64,
            feature_id: None,
        });
    }
    Ok(out)
}

/// Scan every repository under `root` into the catalogue.
///
/// A repository that fails to scan fails the whole run. The alternative —
/// skipping it — writes a catalogue that is missing a repository's worth of
/// tests and says nothing about it, and every count taken from that catalogue
/// afterwards is quietly wrong.
pub fn scan_workspace(root: &Path, repo: &CatalogueRepo) -> Result<Vec<RepoScan>, String> {
    let all = repositories(root);
    let mut report = Vec::new();

    for repo_root in &all {
        let relative = repo_root
            .strip_prefix(root)
            .map(|p| p.display().to_string())
            .unwrap_or_default();

        // Everything under a nested repository belongs to that repository.
        let nested = nested_within(repo_root, &all);
        let mut found = scan_tree(repo_root)?;
        found.retain(|test| {
            let Ok(rel) = test.file_path.strip_prefix(repo_root) else {
                return true;
            };
            !nested.iter().any(|sub| rel.starts_with(sub))
        });

        let numbered = found.iter().filter(|t| t.number.is_some()).count();
        let total = found.len();
        let rows = rows_for(repo_root, &relative, found)?;
        repo.replace_repo(&relative, &rows)
            .map_err(|e| format!("could not record {}'s tests: {e}", display_repo(&relative)))?;

        report.push(RepoScan {
            repo: relative,
            found: total,
            numbered,
        });
    }

    // Repositories that are no longer here are forgotten. Without this a
    // restructure leaves the catalogue holding both spellings of every moved
    // repository, and every query over it — counts, duplicates, free numbers
    // — is answered over two copies of the same tests.
    let scanned: Vec<String> = report.iter().map(|scan| scan.repo.clone()).collect();
    repo.retain_repos(&scanned)
        .map_err(|e| format!("could not forget repositories that have moved: {e}"))?;

    Ok(report)
}

/// The superproject has no path, so it needs a name to appear under.
pub fn display_repo(repo: &str) -> &str {
    if repo.is_empty() {
        "(superproject)"
    } else {
        repo
    }
}