use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use clap::Parser;
use color_eyre::{
Result,
eyre::{Report, bail},
};
use crates_io_api::SyncClient;
use semver::Version;
use serde::Serialize;
use cargo_resolvediff::Platform;
use cargo_resolvediff::diff::Diff;
use cargo_resolvediff::git::Repository;
use cargo_resolvediff::major_updates::{
LatestVersion, ManifestDependencySet, fetch_latest_major_update_for,
};
use cargo_resolvediff::resolve::{Resolved, SpecificCrateIdent};
use cargo_resolvediff::util::{host_platform, locate_project, update};
struct OutputConfig {
templated_output: bool,
templated_major_as_squashed: bool,
templated_in_json: bool,
jinja: minijinja::Environment<'static>,
}
impl OutputConfig {
const MINOR_COMMIT: &str = "minor_commit.jinja";
const MINOR_OUTPUT: &str = "minor_output.jinja";
const MAJOR_COMMIT: &str = "major_commit.jinja";
const MAJOR_OUTPUT: &str = "major_output.jinja";
const SQUASHED_COMMIT: &str = "squashed_commit.jinja";
const SQUASHED_OUTPUT: &str = "squashed_output.jinja";
const GIT_OUTPUT: &str = "git_output.jinja";
const DEFAULT_TEMPLATES: &[(&str, &str)] = &[
(
"_default_templates_body.jinja",
include_str!("default_templates/_default_templates_body.jinja"),
),
(
"_default_templates_helpers.jinja",
include_str!("default_templates/_default_templates_helpers.jinja"),
),
(
Self::MINOR_COMMIT,
include_str!("default_templates/minor_commit.jinja"),
),
(
Self::MINOR_OUTPUT,
include_str!("default_templates/minor_output.jinja"),
),
(
Self::MAJOR_COMMIT,
include_str!("default_templates/major_commit.jinja"),
),
(
Self::MAJOR_OUTPUT,
include_str!("default_templates/major_output.jinja"),
),
(
Self::SQUASHED_COMMIT,
include_str!("default_templates/squashed_commit.jinja"),
),
(
Self::SQUASHED_OUTPUT,
include_str!("default_templates/squashed_output.jinja"),
),
(
Self::GIT_OUTPUT,
include_str!("default_templates/git_output.jinja"),
),
];
const WAS_TEMPLATED_ERR: &str = "Was templated, and as such is always a string";
fn init_jinja(
platforms: &[Platform],
path: Option<PathBuf>,
) -> Result<minijinja::Environment<'static>> {
let mut jinja = minijinja::Environment::new();
let short_platform = {
let mapping = platforms
.iter()
.map(|platform| {
let short = if let Some((short, _)) = platform.0.rsplit_once("-")
&& !platforms
.iter()
.any(|other| platform != other && other.0.starts_with(short))
{
short
} else {
&platform.0
};
(platform.0.clone(), short.replace("-unknown", ""))
})
.collect::<HashMap<_, _>>();
move |platform: String| mapping[&platform].clone()
};
jinja.add_filter("short_platform", short_platform);
if let Some(ref path) = path {
if !path.is_dir() {
bail!("Template directory doesn't exist");
}
jinja.set_loader(minijinja::path_loader(&path));
}
for (name, template) in Self::DEFAULT_TEMPLATES {
if let Some(ref path) = path
&& path.join(name).is_file()
{
jinja.get_template(name)?;
continue;
}
jinja.add_template(name, template)?;
}
Ok(jinja)
}
fn output(
&self,
name: &str,
ctx: minijinja::Value,
commit: Option<&str>,
) -> Result<serde_json::Value> {
let mut ctx = minijinja::context! {
commit => commit,
..ctx
};
if self.templated_in_json {
let templated = self.jinja.get_template(name)?.render(&ctx)?;
ctx = minijinja::context! {
templated => templated,
..ctx
};
}
if self.templated_output {
Ok(self.jinja.get_template(name)?.render(&ctx)?.into())
} else {
Ok(serde_json::to_value(&ctx)?)
}
}
fn minor_commit(&self, diff: &Diff<'_>) -> Result<String> {
Ok(self.jinja.get_template(Self::MINOR_COMMIT)?.render(diff)?)
}
fn minor_output(&self, diff: &Diff<'_>, commit: Option<&str>) -> Result<serde_json::Value> {
self.output(
Self::MINOR_OUTPUT,
minijinja::Value::from_serialize(diff),
commit,
)
}
fn major_context(diff: &Diff<'_>, package: &str, version: &Version) -> minijinja::Value {
minijinja::context! {
package => package,
version => version,
..minijinja::Value::from_serialize(diff),
}
}
fn major_commit(&self, diff: &Diff<'_>, package: &str, version: &Version) -> Result<String> {
let out = self
.jinja
.get_template(Self::MAJOR_COMMIT)?
.render(Self::major_context(diff, package, version))?;
Ok(out)
}
fn major_output(
&self,
diff: &Diff<'_>,
package: &str,
version: &Version,
commit: Option<&str>,
) -> Result<serde_json::Value> {
self.output(
Self::MAJOR_OUTPUT,
Self::major_context(diff, package, version),
commit,
)
}
fn squashed_context(
diff: &Diff<'_>,
major_updates: &[SpecificCrateIdent],
failed_major_updates: &[SpecificCrateIdent],
) -> minijinja::Value {
minijinja::context! {
major_updates => major_updates,
failed_major_updates => failed_major_updates,
..minijinja::Value::from_serialize(diff),
}
}
fn squashed_commit(
&self,
diff: &Diff<'_>,
major_updates: &[SpecificCrateIdent],
failed_major_updates: &[SpecificCrateIdent],
) -> Result<String> {
let out =
self.jinja
.get_template(Self::SQUASHED_COMMIT)?
.render(Self::squashed_context(
diff,
major_updates,
failed_major_updates,
))?;
Ok(out)
}
fn squashed_output(
&self,
diff: &Diff<'_>,
major_updates: &[SpecificCrateIdent],
failed_major_updates: &[SpecificCrateIdent],
commit: Option<&str>,
) -> Result<serde_json::Value> {
self.output(
Self::SQUASHED_OUTPUT,
Self::squashed_context(diff, major_updates, failed_major_updates),
commit,
)
}
fn git_output(&self, diff: &Diff<'_>, from: &str, to: &str) -> Result<serde_json::Value> {
self.output(
Self::GIT_OUTPUT,
minijinja::context! {
from => from,
to => to,
..minijinja::Value::from_serialize(diff),
},
Some(to),
)
}
fn merged_major_output(
&self,
squashed_diff: &Diff<'_>,
updates: &MajorUpdates,
) -> Result<serde_json::Value> {
if self.templated_major_as_squashed {
self.squashed_output(
squashed_diff,
&updates.major_order,
&updates.failed_major_updates,
None,
)
} else if self.templated_output {
let mut out = updates
.minor
.as_str()
.expect(Self::WAS_TEMPLATED_ERR)
.to_owned();
for i in &updates.major_order {
while !out.ends_with("\n\n") {
out.push('\n');
}
let update = &updates.major_updates[&i.name];
out.push_str(update.as_str().expect(Self::WAS_TEMPLATED_ERR));
}
Ok(out.into())
} else {
Ok(serde_json::to_value(updates)?)
}
}
fn final_output(&self, value: &serde_json::Value) -> Result<()> {
if self.templated_output {
println!("{}", value.as_str().expect(Self::WAS_TEMPLATED_ERR));
} else {
output_json(value)?;
}
Ok(())
}
}
fn output_json(value: &impl Serialize) -> Result<()> {
use std::io::{self, IsTerminal};
if io::stdout().is_terminal() {
println!("{}", serde_json::to_string_pretty(value)?);
} else {
println!("{}", serde_json::to_string(value)?);
}
Ok(())
}
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
#[arg(long)]
manifest_path: Option<PathBuf>,
#[arg(short, long)]
platform: Vec<String>,
#[arg(short = 'P', long)]
filter_to_platforms: bool,
#[arg(short = 'c', long)]
check: bool,
#[arg(short = 'm', long, requires("git"))]
major: bool,
#[arg(short = 'M', long, conflicts_with("major"))]
squashed_major: bool,
#[arg(short, long)]
git: bool,
#[arg(long, conflicts_with_all(["major", "squashed_major"]), requires("git"))]
from: Option<String>,
#[arg(long, conflicts_with_all(["major", "squashed_major"]), requires("git"))]
to: Option<String>,
#[arg(short, long)]
templated: bool,
#[arg(short, long, requires("major"), conflicts_with("templated"))]
templated_as_squashed: bool,
#[arg(long, conflicts_with_all(["templated", "templated_as_squashed"]))]
templated_in_json: bool,
#[arg(short = 'T', long, verbatim_doc_comment)]
template_path: Option<PathBuf>,
}
#[derive(Clone)]
enum Task {
Minor,
Major,
Squashed,
Git {
from: String,
to: String,
return_to: String,
},
}
struct AppContext {
manifest_path: PathBuf,
lock_path: PathBuf,
platforms: Vec<Platform>,
include_all_platforms: bool,
check: bool,
repository: Option<Repository>,
output: OutputConfig,
task: Task,
}
impl TryFrom<Args> for AppContext {
type Error = Report;
fn try_from(args: Args) -> Result<Self> {
let manifest_path = args.manifest_path.map_or_else(locate_project, Ok)?;
if manifest_path.extension() != Some("toml".as_ref()) {
bail!("A manifest path should in \".toml\", found {manifest_path:?}");
}
let lock_path = manifest_path.with_extension("lock");
let platforms = if args.platform.is_empty() {
vec![host_platform()?]
} else {
args.platform.into_iter().map(Platform).collect::<Vec<_>>()
};
let mut repository = args.git.then(|| {
let repository_path = manifest_path.parent().expect("there was a file name");
let repository_path = (repository_path != "").then(|| repository_path.to_owned());
Repository::new(repository_path)
});
let output = OutputConfig {
templated_output: args.templated || args.templated_as_squashed,
templated_major_as_squashed: args.templated_as_squashed,
templated_in_json: args.templated_in_json,
jinja: OutputConfig::init_jinja(&platforms, args.template_path)?,
};
let task = if args.major {
Task::Major
} else if args.squashed_major {
Task::Squashed
} else if args.from.is_some() || args.to.is_some() {
let repository = repository.as_mut().expect("--from & --to require --git");
let current = repository.current_branch_or_commit()?;
let fix = |target: Option<_>| target.filter(|s| s != "HEAD").unwrap_or(current.clone());
Task::Git {
from: fix(args.from),
to: fix(args.to),
return_to: current,
}
} else {
Task::Minor
};
Ok(AppContext {
manifest_path,
lock_path,
platforms,
include_all_platforms: !args.filter_to_platforms,
check: args.check,
repository,
output,
task,
})
}
}
struct MajorUpdateContext {
manifest_deps: ManifestDependencySet,
client: SyncClient,
}
impl MajorUpdateContext {
fn new(resolved: &Resolved) -> Result<(Self, Vec<String>)> {
let manifest_deps = ManifestDependencySet::collect(&resolved.full_metadata)?;
let direct_dependencies = manifest_deps.dependencies.keys().cloned().collect();
let client = SyncClient::new(
"cargo-resolvediff (42triangles@tutanota.com)",
std::time::Duration::from_millis(1000),
)?;
let ctx = MajorUpdateContext {
manifest_deps,
client,
};
Ok((ctx, direct_dependencies))
}
fn update_for(&mut self, name: String) -> Result<Option<SpecificCrateIdent>> {
let mentions = self
.manifest_deps
.dependencies
.get_mut(&name)
.expect("Key should have been collected from that map");
let version = match fetch_latest_major_update_for(
&self.client,
&name,
mentions.iter().map(|mention| mention.version()),
)? {
LatestVersion::CrateNotFound | LatestVersion::NoMajorUpdates => return Ok(None),
LatestVersion::NewestUpdate(version) => version,
};
let crate_version = SpecificCrateIdent { name, version };
self.manifest_deps
.manifests
.update_versions_in_file(mentions, &crate_version.version)?;
Ok(Some(crate_version))
}
fn git_commit_after_update(
&self,
lock: &Path,
repository: &mut Repository,
message: &str,
) -> Result<String> {
repository.add(lock)?;
for manifest in self.manifest_deps.manifests.as_slice() {
repository.add(manifest.path())?;
}
let commit = repository
.commit(message)?
.expect("There should have been changes after a major update");
Ok(commit)
}
}
#[derive(Serialize)]
struct MajorUpdates {
minor: serde_json::Value,
major_order: Vec<SpecificCrateIdent>,
major_updates: BTreeMap<String, serde_json::Value>,
failed_major_updates: Vec<SpecificCrateIdent>,
}
impl AppContext {
fn try_update(&self) -> Result<bool> {
update(&self.manifest_path, self.check)
}
fn minor_update(&self) -> Result<()> {
if !self.try_update()? {
bail!("Minor updates failed");
}
Ok(())
}
fn resolve(&self) -> Result<Resolved> {
Resolved::resolve_from_path(
&self.manifest_path,
self.platforms.iter().cloned(),
self.include_all_platforms,
)
}
fn minor_update_task(&mut self) -> Result<(Resolved, serde_json::Value)> {
let before = self.resolve()?;
self.minor_update()?;
let after = self.resolve()?;
let diff = Diff::between(&before, &after);
let commit = if let Some(ref mut repo) = self.repository {
repo.add(&self.lock_path)?;
repo.commit(&self.output.minor_commit(&diff)?)?
} else {
None
};
let output = self.output.minor_output(&diff, commit.as_deref())?;
Ok((after, output))
}
fn major_update_task(&mut self) -> Result<serde_json::Value> {
let first = self.resolve()?;
let mut last_owned;
let mut last = &first;
let (mut major_ctx, direct_dependencies) = MajorUpdateContext::new(&first)?;
let mut major_order = Vec::new();
let mut major_updates = BTreeMap::new();
let mut failed_major_updates = Vec::new();
major_ctx.manifest_deps.commit()?;
for package in direct_dependencies {
major_ctx.manifest_deps.roll_back()?;
let Some(package) = major_ctx.update_for(package)? else {
continue;
};
if !self.try_update()? {
failed_major_updates.push(package);
continue;
};
let resolve = self.resolve()?;
let diff = Diff::between(last, &resolve);
let message = self
.output
.major_commit(&diff, &package.name, &package.version)?;
let repository = self
.repository
.as_mut()
.expect("Split major updates require a git repository");
let commit =
major_ctx.git_commit_after_update(&self.lock_path, repository, &message)?;
let output =
self.output
.major_output(&diff, &package.name, &package.version, Some(&commit))?;
major_ctx.manifest_deps.commit()?;
major_order.push(package.clone());
major_updates.insert(package.name, output);
last_owned = resolve;
last = &last_owned;
}
let (last, minor) = self.minor_update_task()?;
let squashed_diff = Diff::between(&first, &last);
self.output.merged_major_output(
&squashed_diff,
&MajorUpdates {
minor,
major_order,
major_updates,
failed_major_updates,
},
)
}
fn squashed_update_task(&mut self) -> Result<serde_json::Value> {
let before = self.resolve()?;
let (mut major_ctx, direct_dependencies) = MajorUpdateContext::new(&before)?;
let mut major_updates = Vec::new();
let mut failed_major_updates = Vec::new();
major_ctx.manifest_deps.commit()?;
for package in direct_dependencies {
major_ctx.manifest_deps.roll_back()?;
let Some(package) = major_ctx.update_for(package)? else {
continue;
};
if !self.try_update()? {
failed_major_updates.push(package);
continue;
};
major_ctx.manifest_deps.commit()?;
major_updates.push(package);
}
self.minor_update()?;
let after = self.resolve()?;
let diff = Diff::between(&before, &after);
let message = self
.output
.squashed_commit(&diff, &major_updates, &failed_major_updates)?;
let commit = self
.repository
.as_mut()
.map(|repository| {
major_ctx.git_commit_after_update(&self.lock_path, repository, &message)
})
.transpose()?;
let output = self.output.squashed_output(
&diff,
&major_updates,
&failed_major_updates,
commit.as_deref(),
)?;
Ok(output)
}
fn git_task(&mut self, from: &str, to: &str, return_to: &str) -> Result<serde_json::Value> {
let mut repository = self
.repository
.take()
.expect("git comparisons require a repository");
repository.checkout(from)?;
let from_commit = repository.current_commit()?;
let from = self.resolve()?;
repository.checkout(return_to)?;
repository.checkout(to)?;
let to_commit = repository.current_commit()?;
let to = self.resolve()?;
repository.checkout(return_to)?;
self.repository = Some(repository);
let output =
self.output
.git_output(&Diff::between(&from, &to), &from_commit, &to_commit)?;
Ok(output)
}
}
fn main() -> Result<()> {
color_eyre::install()?;
let mut ctx = AppContext::try_from(Args::parse())?;
let out = match ctx.task.clone() {
Task::Minor => ctx.minor_update_task()?.1,
Task::Major => ctx.major_update_task()?,
Task::Squashed => ctx.squashed_update_task()?,
Task::Git {
from,
to,
return_to,
} => ctx.git_task(&from, &to, &return_to)?,
};
ctx.output.final_output(&out)?;
Ok(())
}