use crate::BuildTarget;
use crate::lint;
use anyhow::{Context, Result};
use semver::{Version, VersionReq};
use serde::Deserialize;
use std::path::{Path, PathBuf};
pub const CONFIG_NAME: &str = "htl.toml";
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HtlConfig {
#[serde(default)]
pub toolchain: ToolchainConfig,
#[serde(default)]
pub lint: LintConfig,
#[serde(default)]
pub fmt: FmtConfig,
#[serde(default)]
pub check: CheckConfig,
#[serde(default)]
pub build: BuildConfig,
#[serde(default)]
pub fix: FixConfig,
#[serde(default)]
pub cache: CacheConfig,
#[serde(default)]
pub contract: Vec<Contract>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ToolchainConfig {
pub htl: Option<String>,
}
impl ToolchainConfig {
pub fn req(&self) -> Result<Option<VersionReq>> {
let Some(text) = &self.htl else {
return Ok(None);
};
match VersionReq::parse(text) {
Ok(req) => Ok(Some(req)),
Err(e) => Err(anyhow::anyhow!(
"[toolchain] htl = \"{text}\" is not a version requirement: {e}"
)),
}
}
}
pub fn check_toolchain(cfg: &HtlConfig, path: &Path, running: &str) -> Result<()> {
let Some(req) = cfg.toolchain.req()? else {
return Ok(());
};
let version = Version::parse(running)
.with_context(|| format!("this htl reports its version as {running}, which is not one"))?;
if req.matches(&version) {
return Ok(());
}
let text = cfg.toolchain.htl.as_deref().unwrap_or_default();
anyhow::bail!(
"htl {running} does not satisfy the toolchain this project asks for\n \
{}: [toolchain] htl = \"{text}\"\n \
htl installs nothing: cargo install htl-cli --version \"{text}\"",
path.display()
)
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CacheConfig {
pub mode: Option<String>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum RequireFields {
All(bool),
Named(Vec<String>),
}
impl Default for RequireFields {
fn default() -> Self {
Self::All(false)
}
}
impl RequireFields {
pub fn is_on(&self) -> bool {
match self {
Self::All(b) => *b,
Self::Named(names) => !names.is_empty(),
}
}
pub fn named(&self) -> Option<&[String]> {
match self {
Self::Named(names) => Some(names),
Self::All(_) => None,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Contract {
pub dir: String,
pub module: Option<String>,
#[serde(default)]
pub exclude: Vec<String>,
pub enforced_by: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LintConfig {
#[serde(default)]
pub rules: std::collections::BTreeMap<String, lint::Level>,
pub strict: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FmtConfig {
pub indent: Option<usize>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CheckConfig {
#[serde(default)]
pub paths: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BuildConfig {
#[serde(default)]
pub extra: Vec<String>,
#[serde(default)]
pub host: Vec<String>,
#[serde(default)]
pub target: Option<BuildTarget>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FixConfig {
#[serde(default, rename = "unsafe")]
pub unsafe_: Vec<String>,
#[serde(default)]
pub disable: Vec<String>,
}
impl HtlConfig {
pub fn parse(text: &str) -> Result<Self> {
let cfg: Self = toml::from_str(text)
.map_err(
|e| match (moved_contract_key(text), removed_lint_lists(text)) {
(Some(k), _) => anyhow::anyhow!(
"[[contract]] {k} moved onto the type: mark the record \
`---@contract` and its mandatory fields `---@required`, and leave \
`dir` (with `module` / `exclude` if you use them) here"
),
(_, Some(msg)) => anyhow::anyhow!("{msg}"),
_ => anyhow::Error::from(e),
},
)
.context("parsing htl.toml")?;
cfg.toolchain.req().context("parsing htl.toml")?;
Ok(cfg)
}
pub fn find(start: &Path) -> Result<Option<(PathBuf, Self)>> {
let mut dir = if start.is_dir() {
start.to_path_buf()
} else {
crate::parent_dir(start)
};
if let Ok(abs) = std::fs::canonicalize(&dir) {
dir = abs;
}
loop {
let path = dir.join(CONFIG_NAME);
if path.is_file() && crate::pkg::owning_project(&dir).is_none() {
let text = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
let cfg = Self::parse(&text).with_context(|| path.display().to_string())?;
return Ok(Some((path, cfg)));
}
if !dir.pop() {
return Ok(None);
}
}
}
pub fn lint_spec(&self) -> String {
self.lint
.rules
.iter()
.map(|(rule, level)| format!("{rule}={level}"))
.collect::<Vec<_>>()
.join(",")
}
pub fn search_paths(&self, root: &Path) -> Vec<PathBuf> {
let types = root.join("types");
let mut out = vec![root.to_path_buf(), root.join("src"), types.clone()];
out.extend(crate::materialised_types_dirs(&types));
for p in &self.check.paths {
out.push(resolve_path(root, p));
}
out.retain(|p| p.is_dir());
out.dedup();
out
}
}
fn moved_contract_key(text: &str) -> Option<&'static str> {
let mut in_contract = false;
for line in text.lines().map(str::trim) {
if line.starts_with('[') {
in_contract = line.starts_with("[[contract]]");
continue;
}
if !in_contract {
continue;
}
for k in ["type", "require_fields"] {
if line
.strip_prefix(k)
.is_some_and(|r| r.trim_start().starts_with('='))
{
return Some(k);
}
}
}
None
}
fn removed_lint_lists(text: &str) -> Option<String> {
let table: toml::Table = toml::from_str(text).ok()?;
let lint = table.get("lint")?.as_table()?;
let names = |key: &str| -> Vec<String> {
lint.get(key)
.and_then(toml::Value::as_array)
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
};
let (enabled, disabled) = (names("enable"), names("disable"));
let present: Vec<&str> = ["enable", "disable"]
.into_iter()
.filter(|k| lint.contains_key(*k))
.collect();
if present.is_empty() {
return None;
}
let mut lines = vec![format!(
"[lint] {} replaced by a level per rule. Write instead:\n\n [lint.rules]",
present.join(" and ")
)];
let width = enabled
.iter()
.chain(&disabled)
.map(|r| r.len())
.max()
.unwrap_or(0);
for (rules, level, was) in [
(&enabled, lint::Level::Warn, "enable"),
(&disabled, lint::Level::Allow, "disable"),
] {
for rule in rules {
let assign = format!(
"\"{rule}\"{:pad$} = \"{level}\"",
"",
pad = width - rule.len()
);
lines.push(format!(" {assign:<w$} # was in {was}", w = width + 12));
}
}
if enabled.is_empty() && disabled.is_empty() {
lines.push(" \"nil-index\" = \"deny\"".to_string());
}
lines.push(String::new());
lines.push(
"allow = not reported, warn = reported and advisory, deny = reported and fails \
the run (htl check --list-lints lists every rule with its default)"
.to_string(),
);
Some(lines.join("\n"))
}
pub fn join_specs<'a>(specs: impl IntoIterator<Item = &'a str>) -> String {
specs
.into_iter()
.filter(|s| !s.trim().is_empty())
.collect::<Vec<_>>()
.join(",")
}
pub fn resolve_path(root: &Path, p: &str) -> PathBuf {
if let Some(rest) = p.strip_prefix("~/")
&& let Some(home) = std::env::var_os("HOME")
{
return PathBuf::from(home).join(rest);
}
let pb = PathBuf::from(p);
if pb.is_absolute() { pb } else { root.join(pb) }
}