use std::io::Write;
use anyhow::{Result, bail};
use qk_affected::{Analysis, Cause, Decision, Kept, Reason, TaskDecision};
use qk_config::Workspace;
use qk_graph::graph_order;
use serde_json::json;
const LISTED: usize = 20;
pub fn explain(
workspace: &Workspace,
analysis: &Analysis,
project: Option<&str>,
json: bool,
out: &mut impl Write,
) -> Result<()> {
if let Some(project) = project
&& !workspace.projects.contains_key(project)
{
bail!("unknown project {project:?}");
}
match (project, json) {
(None, true) => {
serde_json::to_writer_pretty(&mut *out, analysis)?;
writeln!(out)?;
}
(Some(project), true) => {
let chain = analysis.chain(project);
let steps: Vec<_> = chain
.iter()
.flatten()
.map(|name| match analysis.cause(name).expect("a chain step is affected") {
Cause::DependsOn {
project,
kind,
also,
} => {
json!({"project": name, "dependsOn": project, "kind": kind, "alsoDependsOn": also})
}
Cause::Touched { reasons } => json!({"project": name, "reasons": reasons}),
})
.collect();
let mut report = json!({
"project": project,
"affected": chain.is_some(),
"base": analysis.base,
"head": analysis.head,
"chain": steps,
});
if let Some(decision) = analysis.reachability.get(project) {
report["reachability"] = serde_json::to_value(decision)?;
}
if !analysis.projections.is_empty() {
report["projections"] = serde_json::to_value(&analysis.projections)?;
report["originalFiles"] = serde_json::to_value(&analysis.original_files)?;
report["files"] = serde_json::to_value(&analysis.files)?;
}
serde_json::to_writer_pretty(&mut *out, &report)?;
writeln!(out)?;
}
(None, false) => {
writeln!(out, "{}", comparison(analysis))?;
explain_projections(analysis, out)?;
let names = graph_order(&workspace.projects, analysis.projects.keys().cloned());
let width = names.iter().map(String::len).max().unwrap_or(0);
for name in names {
let summary = match &analysis.projects[&name] {
Cause::Touched { reasons } => {
let more = match reasons.len() {
1 => String::new(),
count => format!(" (and {} more)", count - 1),
};
format!("touched: {}{more}", reasons[0])
}
Cause::DependsOn { project, .. } => format!("depends on {project}"),
};
match analysis.reachability.get(&name) {
Some(decision) => writeln!(out, "{name:width$} {summary}; {decision}")?,
None => writeln!(out, "{name:width$} {summary}")?,
}
}
let left_out = graph_order(
&workspace.projects,
analysis
.reachability
.iter()
.filter(|(_, decision)| !matches!(decision, Decision::Kept { .. }))
.map(|(name, _)| name.clone()),
);
if !left_out.is_empty() {
writeln!(out, "Left out by import reachability:")?;
let width = left_out.iter().map(String::len).max().unwrap_or(0);
for name in left_out {
let decision = &analysis.reachability[&name];
writeln!(out, " {name:width$} {decision}")?;
}
}
}
(Some(project), false) => {
writeln!(out, "{}", comparison(analysis))?;
explain_projections(analysis, out)?;
let Some(chain) = analysis.chain(project) else {
match analysis.reachability.get(project) {
Some(Decision::LeftOut { tasks }) => {
writeln!(
out,
"{project} is left out: import reachability left out each task of it a change affects."
)?;
let width = tasks.keys().map(String::len).max().unwrap_or(0);
for (id, decision) in tasks {
writeln!(out, " {id:width$} {decision}")?;
}
}
_ => writeln!(out, "{project} is not affected.")?,
}
return Ok(());
};
let touched = chain.last().expect("a chain has its project");
if chain.len() == 1 {
writeln!(out, "{project} is affected because it is touched:")?;
} else {
writeln!(
out,
"{project} is affected because it depends on {touched}:"
)?;
for pair in chain.windows(2) {
let Cause::DependsOn { kind, .. } =
analysis.cause(pair[0]).expect("a chain step is affected")
else {
unreachable!("only the last step of a chain is touched");
};
writeln!(out, " {} -> {} ({kind})", pair[0], pair[1])?;
}
if let Cause::DependsOn { also, .. } = &analysis.projects[project]
&& !also.is_empty()
{
writeln!(
out,
" {project} also depends on affected {} (see `qk show affected <project>`)",
also.join(", ")
)?;
}
writeln!(out, "{touched} is touched:")?;
}
let Cause::Touched { reasons } = analysis
.cause(touched)
.expect("a chain ends at an affected project")
else {
unreachable!("a chain ends at a touched project");
};
for reason in reasons {
writeln!(out, " {reason}")?;
if let Reason::Installs {
direct,
added,
removed,
changed,
..
} = reason
{
let lines = installs(direct, added, removed, changed);
for line in lines.iter().take(LISTED) {
writeln!(out, " {line}")?;
}
if lines.len() > LISTED {
writeln!(out, " ... {} more packages", lines.len() - LISTED)?;
}
}
}
if let Some(decision @ Decision::Kept { decision: task, .. }) =
analysis.reachability.get(project)
{
writeln!(out, "Import reachability {decision}")?;
if let Some(TaskDecision::Whole {
why: Kept::Reached { chain, .. },
}) = task
&& chain.len() > 2
{
for pair in chain.windows(2) {
writeln!(out, " {} imports {}", pair[0], pair[1])?;
}
}
}
}
}
Ok(())
}
fn explain_projections(analysis: &Analysis, out: &mut impl Write) -> Result<()> {
for projection in &analysis.projections {
writeln!(
out,
"Projection {}: {}; {} source change{}, {} artifact change{}{}.",
projection.name,
projection.status,
projection.sources.len(),
if projection.sources.len() == 1 {
""
} else {
"s"
},
projection.artifacts.len(),
if projection.artifacts.len() == 1 {
""
} else {
"s"
},
projection
.detail
.as_ref()
.map(|detail| format!("; {detail}"))
.unwrap_or_default()
)?;
for path in &projection.artifacts {
writeln!(out, " {path}")?;
}
}
Ok(())
}
fn comparison(analysis: &Analysis) -> String {
let mut text = comparison_of(
analysis
.original_files
.as_ref()
.map_or(analysis.files.len(), Vec::len),
analysis.base.as_deref(),
analysis.head.as_deref(),
analysis.range.as_ref(),
);
if analysis.original_files.is_some() {
text.push_str(&format!(
"\n{} {} after projection.",
analysis.files.len(),
if analysis.files.len() == 1 {
"file"
} else {
"files"
}
));
}
text
}
fn comparison_of(
count: usize,
base: Option<&str>,
head: Option<&str>,
range: Option<&qk_affected::Range>,
) -> String {
let short = |revision: &str| revision.chars().take(12).collect::<String>();
let files = if count == 1 { "file" } else { "files" };
let mut text = format!(
"{count} changed {files} between {} and {}.",
base.map_or("(no base)".into(), short),
head.map_or("the working tree".into(), short)
);
if let Some(range) = range {
let left = match &range.upstream {
Some(upstream) => format!("{upstream}, newer than {}", range.requested),
None => range.requested.clone(),
};
let commits = if range.commits == 1 {
"commit"
} else {
"commits"
};
text.push_str(&format!(
"\nThe base is where {} left {left}, {} {commits} back{}.",
head.map_or("HEAD".into(), short),
range.commits,
if head.is_none() {
", and the working tree counts too"
} else {
""
}
));
if let Some(warning) = landed_warning(range) {
text.push_str(&format!("\nwarning: {warning}"));
}
}
text
}
pub fn landed_warning(range: &qk_affected::Range) -> Option<String> {
let branch = range.default_branch.as_deref()?;
(range.landed > 0).then(|| {
format!(
"{} of the {} commits compared are already on {branch}, so what they changed counts as affected; `--base {branch}` compares from where this branch left it",
range.landed, range.commits
)
})
}
fn installs(
direct: &[String],
added: &[String],
removed: &[String],
changed: &[String],
) -> Vec<String> {
use std::collections::{BTreeMap, BTreeSet};
let direct: BTreeSet<&str> = direct
.iter()
.filter_map(|line| line.split_once(':').map(|(name, _)| name))
.collect();
#[derive(Default)]
struct Versions<'a> {
before: BTreeSet<&'a str>,
after: BTreeSet<&'a str>,
}
let mut packages: BTreeMap<&str, Versions> = BTreeMap::new();
for key in removed {
let (name, version) = split(key);
packages.entry(name).or_default().before.insert(version);
}
for key in added {
let (name, version) = split(key);
packages.entry(name).or_default().after.insert(version);
}
for key in changed {
packages.entry(split(key).0).or_default();
}
let mut lines: Vec<_> = packages
.into_iter()
.map(|(name, versions)| {
let list = |set: &BTreeSet<&str>| set.iter().copied().collect::<Vec<_>>().join(", ");
let (rank, what) = if versions.before.is_empty() && versions.after.is_empty() {
(3, "dependencies or integrity changed".to_owned())
} else if versions.before.is_empty() {
(1, format!("added {}", list(&versions.after)))
} else if versions.after.is_empty() {
(1, format!("removed {}", list(&versions.before)))
} else if versions.before == versions.after {
(
2,
format!("{}: peers or patch changed", list(&versions.after)),
)
} else {
(
0,
format!("{} -> {}", list(&versions.before), list(&versions.after)),
)
};
let marker = if direct.contains(name) {
" (direct)"
} else {
""
};
(rank, format!("{name}{marker}: {what}"))
})
.collect();
lines.sort();
lines.into_iter().map(|(_, line)| line).collect()
}
fn split(key: &str) -> (&str, &str) {
let key = &key[..key.find('(').unwrap_or(key.len())];
let start = usize::from(key.starts_with('@'));
match key[start..].find('@') {
Some(at) => (&key[..start + at], &key[start + at + 1..]),
None => (key, ""),
}
}
pub fn tasks(
_workspace: &Workspace,
graph: &qk_taskgraph::TaskGraph,
analysis: Option<&qk_affected::TaskAnalysis>,
json: bool,
out: &mut impl Write,
) -> Result<()> {
let Some(analysis) = analysis else {
let ids: Vec<&String> = graph.tasks.keys().collect();
if json {
serde_json::to_writer(&mut *out, &ids)?;
writeln!(out)?;
} else {
for id in ids {
writeln!(out, "{id}")?;
}
}
return Ok(());
};
if json {
serde_json::to_writer_pretty(&mut *out, analysis)?;
writeln!(out)?;
return Ok(());
}
writeln!(
out,
"{}",
comparison_of(
analysis.files.len(),
analysis.base.as_deref(),
analysis.head.as_deref(),
analysis.range.as_ref(),
)
)?;
let width = analysis.tasks.keys().map(String::len).max().unwrap_or(0);
for (id, cause) in &analysis.tasks {
let summary = match cause {
qk_affected::TaskCause::Touched { reasons } => {
let more = match reasons.len() {
1 => String::new(),
count => format!(" (and {} more)", count - 1),
};
format!("{}{more}", reasons[0])
}
qk_affected::TaskCause::DependsOn { task } => format!("depends on {task}"),
};
match analysis.reachability.get(id) {
Some(decision) => writeln!(out, "{id:width$} {summary}; {decision}")?,
None => writeln!(out, "{id:width$} {summary}")?,
}
if let Some(qk_affected::TaskDecision::Cases { cases, .. }) = analysis.reachability.get(id)
{
for (case, why) in cases {
writeln!(out, " {case}: {why}")?;
}
}
}
let left_out: Vec<_> = analysis
.reachability
.iter()
.filter(|(id, _)| !analysis.tasks.contains_key(*id))
.collect();
if !left_out.is_empty() {
writeln!(out, "Left out by import reachability:")?;
let width = left_out.iter().map(|(id, _)| id.len()).max().unwrap_or(0);
for (id, decision) in left_out {
writeln!(out, " {id:width$} {decision}")?;
}
}
Ok(())
}