monoripple 0.0.1

Symbol-aware affected target detection for JavaScript and TypeScript monorepos
Documentation
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use ignore::WalkBuilder;
use serde::Deserialize;
use serde_json::Value;

use crate::parser::is_source_file;

#[derive(Clone, Debug)]
pub struct Package {
    pub name: String,
    pub dir: PathBuf,
    pub scripts: BTreeMap<String, String>,
    pub entrypoint: Option<PathBuf>,
    pub exports: BTreeMap<String, PathBuf>,
}

#[derive(Clone, Debug)]
pub struct Target {
    pub package: String,
    pub entrypoint: PathBuf,
}

#[derive(Deserialize)]
struct PackageJson {
    name: Option<String>,
    #[serde(default)]
    scripts: BTreeMap<String, String>,
    main: Option<String>,
    module: Option<String>,
    source: Option<String>,
    exports: Option<Value>,
}

pub fn discover_packages(root: &Path) -> Result<Vec<Package>> {
    let mut packages = Vec::new();
    let walker = WalkBuilder::new(root)
        .hidden(false)
        .filter_entry(|entry| {
            !matches!(
                entry.file_name().to_str(),
                Some(".git" | "node_modules" | "target" | "dist" | "build")
            )
        })
        .build();

    for entry in walker {
        let entry = entry?;
        if entry
            .file_type()
            .is_some_and(|file_type| file_type.is_file())
            && entry.file_name() == "package.json"
        {
            let dir = entry.path().parent().unwrap_or(root).to_path_buf();
            let source = fs::read_to_string(entry.path())
                .with_context(|| format!("failed to read {}", entry.path().display()))?;
            let manifest: PackageJson = serde_json::from_str(&source)
                .with_context(|| format!("failed to parse {}", entry.path().display()))?;
            let name = manifest.name.unwrap_or_else(|| {
                dir.file_name()
                    .and_then(|name| name.to_str())
                    .unwrap_or("root")
                    .to_string()
            });
            let entrypoint = find_entrypoint(
                &dir,
                manifest.source.as_deref(),
                manifest.module.as_deref(),
                manifest.main.as_deref(),
                manifest.exports.as_ref(),
            );
            let exports = package_exports(&dir, manifest.exports.as_ref());
            packages.push(Package {
                name,
                dir,
                scripts: manifest.scripts,
                entrypoint,
                exports,
            });
        }
    }

    packages.sort_by(|left, right| left.name.cmp(&right.name));
    Ok(packages)
}

pub fn discover_source_files(root: &Path, packages: &[Package]) -> Vec<PathBuf> {
    let mut files = Vec::new();
    let walker = WalkBuilder::new(root)
        .hidden(false)
        .filter_entry(|entry| {
            !matches!(
                entry.file_name().to_str(),
                Some(".git" | "node_modules" | "target" | "dist" | "build" | "tests" | "test")
            )
        })
        .build();

    for entry in walker.flatten() {
        if entry
            .file_type()
            .is_some_and(|file_type| file_type.is_file())
            && is_source_file(entry.path())
            && package_for_path(packages, entry.path()).is_some()
        {
            files.push(entry.into_path());
        }
    }

    files.sort();
    files
}

pub fn targets_for(packages: &[Package], script: &str) -> Vec<Target> {
    packages
        .iter()
        .filter(|package| script == "all" || package.scripts.contains_key(script))
        .filter_map(|package| {
            package.entrypoint.as_ref().map(|entrypoint| Target {
                package: package.name.clone(),
                entrypoint: entrypoint.clone(),
            })
        })
        .collect()
}

pub fn package_for_path<'a>(packages: &'a [Package], path: &Path) -> Option<&'a Package> {
    packages
        .iter()
        .filter(|package| path.starts_with(&package.dir))
        .max_by_key(|package| package.dir.components().count())
}

fn find_entrypoint(
    dir: &Path,
    source: Option<&str>,
    module: Option<&str>,
    main: Option<&str>,
    exports: Option<&Value>,
) -> Option<PathBuf> {
    if let Some(path) = wrangler_entrypoint(dir) {
        return Some(path);
    }

    let export_entry = exports.and_then(root_export);
    let declared = [source, module, main, export_entry]
        .into_iter()
        .flatten()
        .map(|candidate| dir.join(candidate.trim_start_matches("./")))
        .find(|candidate| candidate.is_file());

    declared.or_else(|| {
        [
            "src/index.ts",
            "src/index.tsx",
            "index.ts",
            "worker.ts",
            "src/worker.ts",
            "src/main.ts",
        ]
        .into_iter()
        .map(|candidate| dir.join(candidate))
        .find(|candidate| candidate.is_file())
    })
}

fn wrangler_entrypoint(dir: &Path) -> Option<PathBuf> {
    ["wrangler.jsonc", "wrangler.json"]
        .into_iter()
        .map(|name| dir.join(name))
        .find_map(|path| {
            let source = fs::read_to_string(path).ok()?;
            let value: Value = json5::from_str(&source).ok()?;
            let main = value.get("main")?.as_str()?;
            Some(dir.join(main))
        })
}

fn root_export(exports: &Value) -> Option<&str> {
    match exports {
        Value::Object(map) if map.contains_key(".") => export_path(&map["."]),
        value => export_path(value),
    }
}

fn package_exports(dir: &Path, exports: Option<&Value>) -> BTreeMap<String, PathBuf> {
    let mut result = BTreeMap::new();
    let Some(exports) = exports else {
        return result;
    };

    match exports {
        Value::Object(map) if map.keys().any(|key| key.starts_with('.')) => {
            for (key, value) in map {
                if let Some(path) = export_path(value) {
                    result.insert(key.clone(), dir.join(path.trim_start_matches("./")));
                }
            }
        }
        value => {
            if let Some(path) = export_path(value) {
                result.insert(".".to_string(), dir.join(path.trim_start_matches("./")));
            }
        }
    }

    result
}

fn export_path(value: &Value) -> Option<&str> {
    match value {
        Value::String(path) => Some(path),
        Value::Object(conditions) => ["source", "import", "default", "types"]
            .into_iter()
            .find_map(|condition| conditions.get(condition).and_then(export_path)),
        Value::Array(options) => options.iter().find_map(export_path),
        _ => None,
    }
}

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

    use tempfile::tempdir;

    use super::*;

    #[test]
    fn discovers_wrangler_target() {
        let root = tempdir().unwrap();
        let app = root.path().join("apps/worker");
        fs::create_dir_all(app.join("src")).unwrap();
        fs::write(
            app.join("package.json"),
            r#"{"name":"worker","scripts":{"deploy":"wrangler deploy"}}"#,
        )
        .unwrap();
        fs::write(
            app.join("wrangler.jsonc"),
            "{ // worker config\n main: 'src/index.ts', }",
        )
        .unwrap();
        fs::write(app.join("src/index.ts"), "export default {};").unwrap();

        let packages = discover_packages(root.path()).unwrap();
        let targets = targets_for(&packages, "deploy");
        assert_eq!(targets[0].package, "worker");
        assert!(targets[0].entrypoint.ends_with("apps/worker/src/index.ts"));
    }
}