use std::path::{Path, PathBuf};
use serde::Deserialize;
pub const DEFAULT_FILE: &str = "kondo.toml";
#[derive(Debug, Clone, Deserialize)]
pub struct Closure {
pub name: String,
pub keeps: Vec<String>,
#[serde(default)]
#[allow(dead_code)]
pub external: bool,
#[serde(default)]
pub closure: Option<String>,
#[serde(default)]
pub derived: Option<String>,
#[serde(default)]
pub graph: Option<String>,
pub branch: String,
#[serde(default)]
pub repo: Option<String>,
#[serde(default)]
pub from: Vec<String>,
#[serde(default)]
pub base_repo: Vec<String>,
#[serde(default)]
pub runtime_only: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Workspace {
#[serde(default)]
pub user: Option<String>,
#[serde(default)]
pub owned: Option<String>,
#[serde(default)]
pub cull: Option<String>,
#[serde(default)]
pub retired: Vec<String>,
#[serde(default, rename = "closure")]
pub closures: Vec<Closure>,
#[serde(skip)]
pub dir: PathBuf,
}
impl Workspace {
pub fn find(explicit: Option<&str>) -> Result<Option<(Self, PathBuf)>, String> {
let path = match explicit {
Some(p) => PathBuf::from(p),
None => {
let p = PathBuf::from(DEFAULT_FILE);
if !p.exists() {
return Ok(None);
}
p
}
};
let text = std::fs::read_to_string(&path)
.map_err(|e| format!("reading {}: {e}", path.display()))?;
let mut ws: Workspace =
toml::from_str(&text).map_err(|e| format!("parsing {}: {e}", path.display()))?;
ws.dir = path.parent().map(Path::to_path_buf).unwrap_or_default();
if ws.closures.is_empty() {
return Err(format!("{}: no [[closure]] entries", path.display()));
}
Ok(Some((ws, path)))
}
fn resolve(&self, p: &str) -> String {
let joined = self.dir.join(p);
joined.to_string_lossy().into_owned()
}
fn resolved(&self, paths: &[String]) -> toml::Value {
toml::Value::Array(
paths
.iter()
.map(|p| toml::Value::String(self.resolve(p)))
.collect(),
)
}
pub fn closure(&self, name: Option<&str>) -> Result<&Closure, String> {
match name {
None => Ok(&self.closures[0]),
Some(n) => self.closures.iter().find(|c| c.name == n).ok_or_else(|| {
format!(
"no closure named {n} in the workspace; have: {}",
self.closures
.iter()
.map(|c| c.name.as_str())
.collect::<Vec<_>>()
.join(", ")
)
}),
}
}
pub fn essential(&self) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for c in &self.closures {
out.extend(c.keeps.iter().cloned());
out.extend(c.closure.iter().cloned());
out.extend(c.derived.iter().cloned());
}
out.extend(self.retired.iter().cloned());
out
}
pub fn defaults_for(
&self,
subcommand: &str,
closure: Option<&str>,
) -> Result<Option<toml::Table>, String> {
let mut t = toml::Table::new();
let mut set = |k: &str, v: Option<toml::Value>| {
if let Some(v) = v {
t.insert(k.to_string(), v);
}
};
let s = |o: &Option<String>| o.as_ref().map(|p| toml::Value::String(self.resolve(p)));
let user = self.user.clone().map(toml::Value::String);
let walk_flags = |c: &Closure, set: &mut dyn FnMut(&str, Option<toml::Value>)| {
set("branch", Some(toml::Value::String(c.branch.clone())));
set("repo", c.repo.clone().map(toml::Value::String));
if !c.from.is_empty() {
set("from", Some(self.resolved_plain(&c.from)));
}
if !c.base_repo.is_empty() {
set("base-repo", Some(self.resolved_plain(&c.base_repo)));
}
if c.runtime_only {
set("runtime-only", Some(toml::Value::Boolean(true)));
}
};
match subcommand {
"kondo" => {
set(
"inventory",
self.owned
.as_ref()
.map(|o| self.resolved(std::slice::from_ref(o))),
);
set("essential", Some(self.resolved(&self.essential())));
set("user", user);
set("output", s(&self.cull));
}
"act" => {
set(
"inventory",
self.cull
.as_ref()
.map(|c| self.resolved(std::slice::from_ref(c))),
);
set("personal", s(&self.owned));
set("user", user);
}
"announce" => {
set(
"inventory",
self.cull
.as_ref()
.map(|c| self.resolved(std::slice::from_ref(c))),
);
set("user", user);
}
"semver-audit" | "triage-updates" | "triage-retired" | "prune-retired" | "show"
| "validate" => {
set(
"inventory",
self.owned
.as_ref()
.map(|o| self.resolved(std::slice::from_ref(o))),
);
}
"keep" => {
let c = self.closure(closure)?;
set("inventory", Some(self.resolved(&c.keeps)));
set("graph", s(&c.graph));
set("owned", s(&self.owned));
set("deps", s(&c.derived));
walk_flags(c, &mut set);
}
"deps" => {
let c = self.closure(closure)?;
set("inventory", Some(self.resolved(&c.keeps)));
set("graph", s(&c.graph));
set("output", s(&c.closure));
set("fixpoint", s(&self.owned));
walk_flags(c, &mut set);
}
"unkeep" => {
let c = self.closure(closure)?;
set("inventory", Some(self.resolved(&c.keeps)));
set("graph", s(&c.graph));
set(
"deps",
c.derived
.as_ref()
.map(|d| self.resolved(std::slice::from_ref(d))),
);
}
"dependents" => {
let c = self.closure(closure)?;
set("inventory", Some(self.resolved(&c.keeps)));
set("graph", s(&c.graph));
}
"derive" => {
let c = self.closure(closure)?;
set("inventory", Some(self.resolved(&c.keeps)));
set("graph", s(&c.graph));
set("owned", s(&self.owned));
set("output", s(&c.derived));
}
_ => return Ok(None),
}
Ok(Some(t))
}
fn resolved_plain(&self, items: &[String]) -> toml::Value {
toml::Value::Array(items.iter().cloned().map(toml::Value::String).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Workspace {
let mut ws: Workspace = toml::from_str(
r#"
user = "salimma"
owned = "direct.toml"
cull = "cull.toml"
retired = ["retired.toml"]
[[closure]]
name = "fedora"
keeps = ["essential.toml", "essential-rust.toml"]
closure = "closure.toml"
derived = "essential-deps.toml"
graph = "graph.json"
branch = "rawhide"
from = ["rawhide"]
[[closure]]
name = "hs-el9"
external = true
keeps = ["hyperscale.toml"]
derived = "hs-el9-deps.toml"
branch = "hs.el9"
repo = "stack"
"#,
)
.unwrap();
ws.dir = PathBuf::from("/data");
ws
}
#[test]
fn essential_is_every_keep_closure_derived_and_retired() {
assert_eq!(
sample().essential(),
[
"essential.toml",
"essential-rust.toml",
"closure.toml",
"essential-deps.toml",
"hyperscale.toml",
"hs-el9-deps.toml",
"retired.toml"
]
);
}
#[test]
fn kondo_defaults_name_owned_essential_user_and_cull() {
let t = sample().defaults_for("kondo", None).unwrap().unwrap();
assert_eq!(
t["inventory"].as_array().unwrap()[0].as_str(),
Some("/data/direct.toml")
);
assert_eq!(t["essential"].as_array().unwrap().len(), 7);
assert_eq!(t["user"].as_str(), Some("salimma"));
assert_eq!(t["output"].as_str(), Some("/data/cull.toml"));
}
#[test]
fn keep_defaults_follow_the_chosen_closure() {
let ws = sample();
let t = ws.defaults_for("keep", None).unwrap().unwrap();
assert_eq!(t["graph"].as_str(), Some("/data/graph.json"));
assert_eq!(t["branch"].as_str(), Some("rawhide"));
assert_eq!(t["from"].as_array().unwrap()[0].as_str(), Some("rawhide"));
assert!(t.get("repo").is_none());
let t = ws.defaults_for("keep", Some("hs-el9")).unwrap().unwrap();
assert_eq!(t["branch"].as_str(), Some("hs.el9"));
assert_eq!(t["repo"].as_str(), Some("stack"));
assert!(t.get("graph").is_none(), "no graph configured for hs-el9");
assert!(ws.defaults_for("keep", Some("nope")).is_err());
let t = ws.defaults_for("semver-audit", None).unwrap().unwrap();
assert_eq!(
t["inventory"].as_array().unwrap()[0].as_str(),
Some("/data/direct.toml")
);
assert!(ws.defaults_for("export", None).unwrap().is_none());
}
}