monoripple 0.0.1

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

use anyhow::{Result, bail};
use clap::{Parser, Subcommand, ValueEnum};
use serde::Serialize;

use monoripple::analysis::{ChangeSeeds, find_change_seeds};
use monoripple::git::{changed_files, repository_root};
use monoripple::graph::{DependencyGraph, Node};
use monoripple::workspace::{discover_packages, discover_source_files, targets_for};

#[derive(Parser)]
#[command(name = "monoripple", version, about)]
struct Cli {
    #[arg(long, global = true, default_value = ".")]
    root: PathBuf,

    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    Affected(QueryArgs),
    Why {
        package: String,
        #[command(flatten)]
        query: QueryArgs,
    },
}

#[derive(Clone, clap::Args)]
struct QueryArgs {
    #[arg(long, default_value = "origin/main")]
    base: String,

    #[arg(long, default_value = "deploy")]
    target: String,

    #[arg(long, value_enum, default_value = "text")]
    format: OutputFormat,
}

#[derive(Clone, Copy, ValueEnum)]
enum OutputFormat {
    Text,
    Json,
}

#[derive(Serialize)]
struct AffectedOutput {
    base: String,
    target: String,
    packages: Vec<String>,
}

fn main() -> Result<()> {
    let cli = Cli::parse();
    let start = if cli.root.is_absolute() {
        cli.root
    } else {
        env::current_dir()?.join(cli.root)
    };
    let root = repository_root(&start)?;

    match cli.command {
        Command::Affected(query) => run_affected(&root, &query),
        Command::Why { package, query } => run_why(&root, &package, &query),
    }
}

fn run_affected(root: &Path, query: &QueryArgs) -> Result<()> {
    let analysis = analyze(root, query)?;
    let affected = affected_packages(&analysis);

    match query.format {
        OutputFormat::Text => {
            for package in affected {
                println!("{package}");
            }
        }
        OutputFormat::Json => {
            println!(
                "{}",
                serde_json::to_string_pretty(&AffectedOutput {
                    base: query.base.clone(),
                    target: query.target.clone(),
                    packages: affected,
                })?
            );
        }
    }

    Ok(())
}

fn run_why(root: &Path, package: &str, query: &QueryArgs) -> Result<()> {
    let analysis = analyze(root, query)?;

    if analysis.seeds.direct_packages.contains(package) {
        println!("{package} is directly affected by a changed package input");
        return Ok(());
    }

    let Some(target) = analysis
        .graph
        .targets
        .iter()
        .find(|target| target.package == package)
    else {
        bail!("target package '{package}' was not found");
    };
    let Some(path) = analysis.graph.path_to(&analysis.reachability, &target.node) else {
        bail!("target package '{package}' is not affected");
    };

    for (index, node) in path.iter().enumerate() {
        let connector = if index == 0 { "" } else { "  -> " };
        println!("{connector}{}", display_node(root, node));
    }

    Ok(())
}

struct Analysis {
    graph: DependencyGraph,
    seeds: ChangeSeeds,
    reachability: monoripple::graph::Reachability,
}

fn analyze(root: &Path, query: &QueryArgs) -> Result<Analysis> {
    let packages = discover_packages(root)?;
    let files = discover_source_files(root, &packages);
    let targets = targets_for(&packages, &query.target);
    let graph = DependencyGraph::build(&files, &targets, &packages)?;
    let changes = changed_files(root, &query.base)?;
    let seeds = find_change_seeds(root, &query.base, &changes, &packages, &graph)?;
    let reachability = graph.affected(&seeds.nodes);

    Ok(Analysis {
        graph,
        seeds,
        reachability,
    })
}

fn affected_packages(analysis: &Analysis) -> Vec<String> {
    let mut affected = BTreeSet::new();

    for target in &analysis.graph.targets {
        if analysis.reachability.reached.contains(&target.node)
            || analysis.seeds.direct_packages.contains(&target.package)
        {
            affected.insert(target.package.clone());
        }
    }

    affected.into_iter().collect()
}

fn display_node(root: &Path, node: &Node) -> String {
    if node.file == Path::new("<target>") {
        return format!("target {}", node.symbol);
    }

    let path = node.file.strip_prefix(root).unwrap_or(&node.file);
    format!("{}#{}", path.display(), node.symbol)
}