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)
}