cargo-compete 0.10.7

A Cargo subcommand for competitive programming.
Documentation
use crate::shell::Shell;
use anyhow::{bail, Context as _};
use camino::{Utf8Path, Utf8PathBuf};
use cargo_metadata as cm;
use easy_ext::ext;
use indexmap::{indexset, IndexMap};
use itertools::Itertools as _;
use serde::{
    de::{Deserializer, Error as _, IntoDeserializer},
    Deserialize,
};
use serde_json::json;
use std::{
    path::{Path, PathBuf},
    str,
};
use url::Url;

#[derive(Deserialize, Debug, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct PackageMetadataCargoCompete {
    pub(crate) config: Option<Utf8PathBuf>,
    #[serde(default, deserialize_with = "deserialize_bin_example")]
    pub(crate) bin: IndexMap<String, PackageMetadataCargoCompeteBinExample>,
    #[serde(default, deserialize_with = "deserialize_bin_example")]
    pub(crate) example: IndexMap<String, PackageMetadataCargoCompeteBinExample>,
}

fn deserialize_bin_example<'de, D>(
    deserializer: D,
) -> Result<IndexMap<String, PackageMetadataCargoCompeteBinExample>, D::Error>
where
    D: Deserializer<'de>,
{
    let map = IndexMap::<String, Repr>::deserialize(deserializer)?;
    return Ok(map
        .into_iter()
        .map(
            |(
                key,
                Repr {
                    name,
                    alias,
                    problem,
                },
            )| {
                let (name, alias) = if let Some(alias) = alias {
                    (key, alias)
                } else if let Some(name) = name {
                    (name, key)
                } else {
                    (key.clone(), key)
                };
                (
                    name,
                    PackageMetadataCargoCompeteBinExample { alias, problem },
                )
            },
        )
        .collect());

    #[derive(Deserialize)]
    #[serde(rename_all = "kebab-case")]
    struct Repr {
        name: Option<String>,
        alias: Option<String>,
        #[serde(deserialize_with = "deserialize_bin_problem")]
        problem: Url,
    }

    fn deserialize_bin_problem<'de, D>(deserializer: D) -> Result<Url, D::Error>
    where
        D: Deserializer<'de>,
    {
        return match Repr::deserialize(deserializer) {
            Ok(Repr::V1 { url }) | Ok(Repr::V2(url)) => Ok(url),
            Err(_) => Err(D::Error::custom(r#"expected `"<url>" | { url: "<url>" }`"#)),
        };

        #[derive(Deserialize)]
        #[serde(untagged)]
        enum Repr {
            V1 { url: Url },
            V2(Url),
        }
    }
}

impl PackageMetadataCargoCompete {
    pub(crate) fn bin_like_by_name_or_alias(
        &self,
        name_or_alias: impl AsRef<str>,
    ) -> anyhow::Result<(&str, &PackageMetadataCargoCompeteBinExample)> {
        let bin_name_or_alias = name_or_alias.as_ref();

        match *itertools::chain(&self.bin, &self.example)
            .filter(
                |(name, PackageMetadataCargoCompeteBinExample { alias, .. })| {
                    [&**name, &**alias].contains(&bin_name_or_alias)
                },
            )
            .collect::<Vec<_>>()
        {
            [(k, v)] => Ok((k, v)),
            [] => bail!("no `problem` for: {}", bin_name_or_alias),
            [..] => bail!("multiple `problem`s for {}", bin_name_or_alias),
        }
    }
}

#[derive(Debug, PartialEq)]
pub(crate) struct PackageMetadataCargoCompeteBinExample {
    pub(crate) alias: String,
    pub(crate) problem: Url,
}

#[ext(MetadataExt)]
impl cm::Metadata {
    pub(crate) fn all_members(&self) -> Vec<&cm::Package> {
        self.packages
            .iter()
            .filter(|cm::Package { id, .. }| self.workspace_members.contains(id))
            .collect()
    }

    pub(crate) fn query_for_member<S: AsRef<str>>(
        &self,
        spec: Option<S>,
    ) -> anyhow::Result<&cm::Package> {
        if let Some(spec_str) = spec {
            let spec_str = spec_str.as_ref();
            let spec = spec_str.parse::<krates::PkgSpec>()?;

            match *self
                .packages
                .iter()
                .filter(|package| {
                    self.workspace_members.contains(&package.id) && spec.matches(package)
                })
                .collect::<Vec<_>>()
            {
                [] => bail!("package `{}` is not a member of the workspace", spec_str),
                [member] => Ok(member),
                [_, _, ..] => bail!("`{}` matched multiple members?????", spec_str),
            }
        } else {
            let current_member = self
                .resolve
                .as_ref()
                .and_then(|cm::Resolve { root, .. }| root.as_ref())
                .map(|root| &self[root]);

            if let Some(current_member) = current_member {
                Ok(current_member)
            } else {
                match *self.workspace_members.iter().collect::<Vec<_>>() {
                    [] => bail!("this workspace has no members",),
                    [one] => Ok(&self[one]),
                    [..] => {
                        bail!(
                            "this manifest is virtual, and the workspace has {} members. specify \
                             one with `--manifest-path` or `--package`",
                            self.workspace_members.len(),
                        );
                    }
                }
            }
        }
    }
}

#[ext(PackageExt)]
impl cm::Package {
    pub(crate) fn manifest_dir(&self) -> &Utf8Path {
        self.manifest_path
            .parent()
            .expect("`manifest_path` should end with `Cargo.toml`")
    }

    pub(crate) fn read_package_metadata(
        &self,
        shell: &mut Shell,
    ) -> anyhow::Result<PackageMetadataCargoCompete> {
        let unused = &mut indexset!();

        let deserializer = self
            .metadata
            .get("cargo-compete")
            .cloned()
            .unwrap_or_else(|| json!({}))
            .into_deserializer();

        let ret = serde_ignored::deserialize(deserializer, |path| {
            unused.insert(path.to_string());
        })
        .with_context(|| "could not parse `package.metadata.cargo-compete`")?;

        for unused in &*unused {
            shell.warn(format!(
                "unused key in `package.metadata.cargo-compete`: {unused}",
            ))?;
        }

        Ok(ret)
    }

    pub(crate) fn bin_like_target_by_name(
        &self,
        name: impl AsRef<str>,
    ) -> anyhow::Result<&cm::Target> {
        let name = name.as_ref();

        self.targets
            .iter()
            .find(|t| {
                t.name == name && t.kind == ["bin".to_owned()] || t.kind == ["example".to_owned()]
            })
            .with_context(|| format!("no bin/example target named `{}` in `{}`", name, self.name))
    }

    pub(crate) fn bin_target_by_src_path(
        &self,
        src_path: impl AsRef<Path>,
    ) -> anyhow::Result<&cm::Target> {
        let src_path = src_path.as_ref();

        self.targets
            .iter()
            .find(|t| t.src_path == src_path && t.kind == ["bin".to_owned()])
            .with_context(|| {
                format!(
                    "no bin target which `src_path` is `{}` in `{}`",
                    src_path.display(),
                    self.name,
                )
            })
    }

    pub(crate) fn all_bin_targets_sorted(&self) -> Vec<&cm::Target> {
        self.targets
            .iter()
            .filter(|cm::Target { kind, .. }| *kind == ["bin".to_owned()])
            .sorted_by(|t1, t2| t1.name.cmp(&t2.name))
            .collect()
    }
}

pub(crate) fn locate_project(cwd: impl AsRef<Path>) -> anyhow::Result<PathBuf> {
    let cwd = cwd.as_ref();

    cwd.ancestors()
        .map(|p| p.join("Cargo.toml"))
        .find(|p| p.exists())
        .with_context(|| {
            format!(
                "could not find `Cargo.toml` in `{}` or any parent directory. first, run \
                 `cargo compete init` and `cd` to a workspace",
                cwd.display(),
            )
        })
}

pub(crate) fn cargo_metadata(
    manifest_path: impl AsRef<Path>,
    cwd: impl AsRef<Path>,
) -> cm::Result<cm::Metadata> {
    cm::MetadataCommand::new()
        .manifest_path(manifest_path.as_ref())
        .current_dir(cwd.as_ref())
        .exec()
}

pub(crate) fn cargo_metadata_no_deps(
    manifest_path: impl AsRef<Path>,
    cwd: impl AsRef<Path>,
) -> cm::Result<cm::Metadata> {
    cm::MetadataCommand::new()
        .manifest_path(manifest_path.as_ref())
        .no_deps()
        .current_dir(cwd.as_ref())
        .exec()
}

pub(crate) fn set_cargo_config_build_target_dir(
    dir: &Path,
    shell: &mut Shell,
) -> anyhow::Result<()> {
    crate::fs::create_dir_all(dir.join(".cargo"))?;

    let cargo_config_path = dir.join(".cargo").join("config.toml");

    let mut cargo_config = if cargo_config_path.exists() {
        crate::fs::read_to_string(&cargo_config_path)?
    } else {
        r#"[build]
"#
        .to_owned()
    }
    .parse::<toml_edit::Document>()
    .with_context(|| {
        format!(
            "could not parse the TOML file at `{}`",
            cargo_config_path.display(),
        )
    })?;

    if cargo_config.get("build").is_none() {
        let mut tbl = toml_edit::Table::new();
        tbl.set_implicit(true);
        cargo_config["build"] = toml_edit::Item::Table(tbl);
    }
    if { &mut cargo_config["build"]["target-dir"] }.is_none() {
        cargo_config["build"]["target-dir"] = toml_edit::value("target");
        crate::fs::write(&cargo_config_path, cargo_config.to_string())?;
        shell.status("Wrote", cargo_config_path.display())?;
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use crate::project::{PackageMetadataCargoCompete, PackageMetadataCargoCompeteBinExample};
    use indexmap::indexmap;
    use pretty_assertions::assert_eq;
    use toml::toml;

    #[test]
    fn deserialize_package_metadata_cargo_compete() -> anyhow::Result<()> {
        let expected = PackageMetadataCargoCompete {
            config: None,
            bin: indexmap!(
                "practice-a".to_owned() => PackageMetadataCargoCompeteBinExample {
                    alias: "a".to_owned(),
                    problem: "https://atcoder.jp/contests/practice/tasks/practice_1"
                        .parse()
                        .unwrap(),
                },
                "practice-b".to_owned() => PackageMetadataCargoCompeteBinExample {
                    alias: "b".to_owned(),
                    problem: "https://atcoder.jp/contests/practice/tasks/practice_2"
                        .parse()
                        .unwrap(),
                },
            ),
            example: indexmap!(),
        };

        assert_eq!(
            expected,
            toml! {
                [bin]
                practice-a = { alias = "a", problem = "https://atcoder.jp/contests/practice/tasks/practice_1" }
                practice-b = { alias = "b", problem = "https://atcoder.jp/contests/practice/tasks/practice_2" }
            }
            .try_into::<PackageMetadataCargoCompete>()?,
        );

        let expected = PackageMetadataCargoCompete {
            config: None,
            bin: indexmap!(
                "aplusb".to_owned() => PackageMetadataCargoCompeteBinExample {
                    alias: "aplusb".to_owned(),
                    problem: "https://judge.yosupo.jp/problem/aplusb".parse().unwrap(),
                },
            ),
            example: indexmap!(),
        };

        assert_eq!(
            expected,
            toml! {
                [bin]
                aplusb = { problem = "https://judge.yosupo.jp/problem/aplusb" }
            }
            .try_into::<PackageMetadataCargoCompete>()?,
        );

        let expected = PackageMetadataCargoCompete {
            config: None,
            bin: indexmap!(),
            example: indexmap!(
                "aplusb".to_owned() => PackageMetadataCargoCompeteBinExample {
                    alias: "aplusb".to_owned(),
                    problem: "https://judge.yosupo.jp/problem/aplusb".parse().unwrap(),
                },
            ),
        };

        assert_eq!(
            expected,
            toml! {
                [example]
                aplusb = { problem = "https://judge.yosupo.jp/problem/aplusb" }
            }
            .try_into::<PackageMetadataCargoCompete>()?,
        );

        Ok(())
    }
}