use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
pub fn nornir_home_from(home: &Path) -> PathBuf {
home.join(".nornir")
}
pub fn nornir_home() -> PathBuf {
nornir_home_from(&home::home_dir().expect("resolve running user's HOME"))
}
pub fn registry_root() -> PathBuf {
nornir_home().join("workspaces")
}
pub fn warehouse_default_root() -> PathBuf {
nornir_home().join("warehouse")
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct Nornir {
#[serde(default)]
pub guard: Guard,
#[serde(default)]
pub storage: Storage,
#[serde(default)]
pub repo: BTreeMap<String, Repo>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct Guard {
#[serde(default)]
pub forbidden: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct Storage {
#[serde(default)]
pub kind: String,
#[serde(default)]
pub local_path: String,
#[serde(default)]
pub remote_url: String,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct Repo {
#[serde(default)] pub path: String,
#[serde(default)] pub remote: String,
#[serde(default)] pub history: String,
#[serde(default)] pub readme: String,
#[serde(default)] pub publish_order: Vec<Vec<String>>,
#[serde(default)] pub gates: Gates,
#[serde(default)] pub bench: BenchSpec,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct Gates {
#[serde(default)] pub no_path_patches: bool,
#[serde(default)] pub nexus_floor: bool,
#[serde(default)] pub no_regression: bool,
#[serde(default)] pub max_regression_pct: f64,
#[serde(default)] pub integration_roundtrip: Vec<String>,
#[serde(default)] pub docs_fresh: bool,
#[serde(default)] pub guard_intact: bool,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct BenchSpec {
#[serde(default)]
pub required_results: Vec<String>,
#[serde(default)]
pub network_required: bool,
}
impl BenchSpec {
pub fn missing_in<'a>(&'a self, run: &crate::bench::BenchRun) -> Vec<&'a str> {
let produced: std::collections::HashSet<&str> =
run.results.iter().map(|r| r.name.as_str()).collect();
let mut missing: Vec<&str> = self
.required_results
.iter()
.map(|s| s.as_str())
.filter(|n| !produced.contains(n))
.collect();
missing.sort();
missing
}
pub fn validate(&self, run: &crate::bench::BenchRun) -> anyhow::Result<()> {
let missing = self.missing_in(run);
if !missing.is_empty() {
anyhow::bail!(
"bench corpus is missing {} required result(s): [{}]",
missing.len(),
missing.join(", ")
);
}
Ok(())
}
}
pub struct Loaded {
pub nornir: Nornir,
pub config_path: PathBuf,
pub workspace_root: PathBuf,
}
impl Loaded {
pub fn workspace_name(&self) -> String {
self.config_path
.components()
.filter_map(|c| c.as_os_str().to_str())
.find_map(|s| s.strip_prefix("workspace_").map(str::to_string))
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "default".to_string())
}
pub fn warehouse_root(&self) -> PathBuf {
let storage = &self.nornir.storage;
if storage.local_path.is_empty() {
registry_root().join(self.workspace_name()).join("builds")
} else {
self.workspace_root.join(&storage.local_path).join("warehouse")
}
}
pub fn funnel_root(&self) -> PathBuf {
registry_root().join(self.workspace_name()).join("funnel")
}
}
impl Nornir {
pub fn load(path: &Path) -> Result<Self> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("read {}", path.display()))?;
toml::from_str(&text).with_context(|| format!("parse {}", path.display()))
}
pub fn repo_dir(workspace_root: &Path, name: &str) -> PathBuf {
workspace_root.join(name)
}
pub fn repo_dir_for(&self, workspace_root: &Path, name: &str) -> PathBuf {
if let Some(repo) = self.repo.get(name) {
if !repo.path.trim().is_empty() {
let p = Path::new(repo.path.trim());
return if p.is_absolute() {
p.to_path_buf()
} else {
workspace_root.join(p)
};
}
}
repo_dir_resolved(workspace_root, name)
}
}
pub fn repo_dir_resolved(workspace_root: &Path, name: &str) -> PathBuf {
let exact = Nornir::repo_dir(workspace_root, name);
if exact.exists() {
return exact;
}
let scan_root: &Path = if workspace_root.as_os_str().is_empty() {
Path::new(".")
} else {
workspace_root
};
if let Ok(entries) = std::fs::read_dir(scan_root) {
for entry in entries.flatten() {
if entry.file_name().to_string_lossy().eq_ignore_ascii_case(name)
&& entry.path().is_dir()
{
return if workspace_root.as_os_str().is_empty() {
PathBuf::from(entry.file_name())
} else {
entry.path()
};
}
}
}
exact
}
pub fn discover(start: &Path) -> Result<Loaded> {
let mut cur = start
.canonicalize()
.unwrap_or_else(|_| start.to_path_buf());
loop {
let plain = cur.join("nornir.toml");
if plain.is_file() {
return Ok(Loaded { nornir: Nornir::load(&plain)?, config_path: plain, workspace_root: cur });
}
if let Ok(rd) = std::fs::read_dir(&cur) {
let mut hits: Vec<PathBuf> = rd
.flatten()
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("workspace_"))
&& p.join("release/nornir.toml").is_file()
})
.map(|p| p.join("release/nornir.toml"))
.collect();
hits.sort();
if let Some(candidate) = hits.into_iter().next() {
let nornir = Nornir::load(&candidate)?;
return Ok(Loaded { nornir, config_path: candidate, workspace_root: cur });
}
}
if !cur.pop() {
return Err(anyhow!(
"no nornir.toml found walking up from {}. Put a nornir.toml at your project \
root, pass --config / set NORNIR_CONFIG, or (MCP/CLI client mode) set \
NORNIR_SERVER + NORNIR_SERVER_TOKEN to relay to a remote nornir-server with \
no local config.",
start.display()
));
}
}
}
pub fn load_explicit(config_path: &Path) -> Result<Loaded> {
let nornir = Nornir::load(config_path)?;
let workspace_root = config_path
.parent()
.and_then(Path::parent)
.and_then(Path::parent)
.ok_or_else(|| anyhow!("config path lacks grandparent dirs: {}", config_path.display()))?
.to_path_buf();
Ok(Loaded {
nornir,
config_path: config_path.to_path_buf(),
workspace_root,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
static HOME_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn home_root_is_home_dot_nornir_for_any_user() {
assert_eq!(
nornir_home_from(Path::new("/home/alice")),
PathBuf::from("/home/alice/.nornir"),
);
assert_eq!(
nornir_home_from(Path::new("/home/nornir")),
PathBuf::from("/home/nornir/.nornir"),
);
}
#[test]
fn registry_and_warehouse_compose_under_home() {
let _g = HOME_LOCK.lock().unwrap_or_else(|e| e.into_inner());
std::env::set_var("HOME", "/home/bob");
assert_eq!(nornir_home(), PathBuf::from("/home/bob/.nornir"));
assert_eq!(registry_root(), PathBuf::from("/home/bob/.nornir/workspaces"));
assert_eq!(
warehouse_default_root(),
PathBuf::from("/home/bob/.nornir/warehouse"),
);
}
#[test]
fn warehouse_root_default_is_home_derived() {
let _g = HOME_LOCK.lock().unwrap_or_else(|e| e.into_inner());
std::env::set_var("HOME", "/home/carol");
let empty = Loaded {
nornir: Nornir::default(),
config_path: PathBuf::from("/ws/workspace_holger/release/nornir.toml"),
workspace_root: PathBuf::from("/ws"),
};
assert_eq!(
empty.warehouse_root(),
PathBuf::from("/home/carol/.nornir/workspaces/holger/builds"),
);
assert_eq!(empty.workspace_name(), "holger");
assert_eq!(
empty.funnel_root(),
PathBuf::from("/home/carol/.nornir/workspaces/holger/funnel"),
);
let mut pinned = Loaded {
nornir: Nornir::default(),
config_path: PathBuf::from("/ws/workspace_holger/release/nornir.toml"),
workspace_root: PathBuf::from("/ws"),
};
pinned.nornir.storage.local_path = "/fast/disk".into();
assert_eq!(
pinned.warehouse_root(),
PathBuf::from("/fast/disk/warehouse"),
);
}
#[test]
fn repo_dir_for_honors_path_override_relative_and_absolute() {
let ws = Path::new("/ws");
let mut nornir = Nornir::default();
nornir.repo.insert(
"nornir".into(),
Repo { path: "nornir-orch".into(), ..Default::default() },
);
nornir.repo.insert(
"skade".into(),
Repo { path: "/abs/skade-checkout".into(), ..Default::default() },
);
nornir.repo.insert("znippy".into(), Repo::default());
assert_eq!(
nornir.repo_dir_for(ws, "nornir"),
PathBuf::from("/ws/nornir-orch"),
"relative override must join the workspace root, not the by-name dir",
);
assert_eq!(
nornir.repo_dir_for(ws, "skade"),
PathBuf::from("/abs/skade-checkout"),
"absolute override must be honored verbatim",
);
assert_eq!(
nornir.repo_dir_for(ws, "znippy"),
PathBuf::from("/ws/znippy"),
"empty override must fall back to the by-name convention",
);
assert_eq!(
nornir.repo_dir_for(ws, "ghost"),
PathBuf::from("/ws/ghost"),
);
}
}