Domain extension system (ADR-012): bash-layer dispatch activates repo_kind-conditional CLI domains. install.nu copies domains/ tree; short_alias wrappers generated (personal, prov). ore help and describe capabilities domain-aware. personal domain (PersonalOntology): career skills/talks/publications/positioning, CFP pipeline (Watching→Delivered), opportunities lifecycle, content pipeline, Sessionize integration. Daemon pages: /career, /personal. provisioning domain (DevWorkspace/Mixed): FSM state, next transitions, connections graph, gates, workspace card, capabilities, backlog. Daemon page: /provisioning. VCS abstraction layer (ADR-013): reflection/modules/vcs.nu — uniform jj/git API via filesystem detection (.jj/ vs .git/). opmode.nu and git-event.nu migrated off ^git. reflection/bin/jjw.nu — jj + ontoref + Radicle agent workspace lifecycle. jjw-ncl-merge.nu registered as jj merge tool for .ontology/ NCL conflicts. init-repo.nu for new_project mode. jj/rad not in ontoref requirements — belong in orchestration project manifests. 'Framework RepoKind: ontology/schemas/manifest.ncl gains 'Framework variant; ontoref self-identifies as framework — no domain activates for the protocol itself. Web presence: personal.html and provisioning.html domain subpages. index.html gains "Project Types — Domain Extensions" section with type cards and subpage links. Nav compacted (Arch/Prov labels, solid backdrop-filter background). on+re: vcs-abstraction (adrs: adr-013) and agent-workspace-orchestration Practice nodes; 21 manifest capabilities; state.ncl catalysts updated.
850 lines
28 KiB
Rust
850 lines
28 KiB
Rust
use std::path::{Path, PathBuf};
|
|
use std::sync::atomic::AtomicU64;
|
|
use std::sync::{Arc, RwLock};
|
|
|
|
use dashmap::DashMap;
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::sync::Semaphore;
|
|
use tracing::warn;
|
|
|
|
use crate::actors::ActorRegistry;
|
|
use crate::cache::NclCache;
|
|
use crate::notifications::NotificationStore;
|
|
|
|
// ── Config surface
|
|
// ────────────────────────────────────────────────────────────
|
|
|
|
/// Which process reads a config section.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ConsumerKind {
|
|
RustStruct,
|
|
NuScript,
|
|
CiPipeline,
|
|
External,
|
|
}
|
|
|
|
/// A single process that reads fields from a config section.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ConfigConsumer {
|
|
/// Identifier for this consumer (e.g. "vapora-backend", "deploy-script").
|
|
pub id: String,
|
|
pub kind: ConsumerKind,
|
|
/// Rust fully-qualified type or script path.
|
|
pub reference: String,
|
|
/// Fields this consumer reads. Empty = reads all fields.
|
|
pub fields: Vec<String>,
|
|
}
|
|
|
|
/// Describes one NCL config file and all processes that read it.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ConfigSection {
|
|
pub id: String,
|
|
/// Path to the NCL file, relative to `config_root`.
|
|
pub file: String,
|
|
/// Path to the NCL contract file. Relative to `contracts_path` or project
|
|
/// root.
|
|
pub contract: String,
|
|
pub description: String,
|
|
/// Why this section exists and why current values were chosen.
|
|
pub rationale: String,
|
|
/// When false, ontoref will only read this section, never write.
|
|
pub mutable: bool,
|
|
pub consumers: Vec<ConfigConsumer>,
|
|
}
|
|
|
|
/// How the project's config files are organised.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ConfigKind {
|
|
/// Multiple .ncl files merged via & operator.
|
|
NclMerge,
|
|
/// .typedialog/ structure with form.toml + validators + fragments.
|
|
TypeDialog,
|
|
/// Single monolithic .ncl file.
|
|
SingleFile,
|
|
}
|
|
|
|
/// Project-level config surface metadata — loaded once from manifest.ncl at
|
|
/// registration time and stored in `ProjectContext`.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ConfigSurface {
|
|
/// Directory containing config NCL files, relative to project root.
|
|
pub config_root: PathBuf,
|
|
/// Main NCL file (entry point for `nickel export`).
|
|
pub entry_point: String,
|
|
pub kind: ConfigKind,
|
|
/// Directory added to NICKEL_IMPORT_PATH when exporting config.
|
|
pub contracts_path: String,
|
|
/// Where ontoref writes `{section}.overrides.ncl` files.
|
|
/// Defaults to `config_root` when empty.
|
|
pub overrides_dir: PathBuf,
|
|
pub sections: Vec<ConfigSection>,
|
|
}
|
|
|
|
impl ConfigSurface {
|
|
/// Resolve the directory where override files are written.
|
|
/// Returns `overrides_dir` if set, otherwise `config_root`.
|
|
pub fn resolved_overrides_dir(&self) -> &Path {
|
|
if self.overrides_dir == PathBuf::new() {
|
|
&self.config_root
|
|
} else {
|
|
&self.overrides_dir
|
|
}
|
|
}
|
|
|
|
/// Resolve the absolute path to the config entry point given the project
|
|
/// root.
|
|
pub fn entry_point_path(&self, project_root: &Path) -> PathBuf {
|
|
project_root.join(&self.config_root).join(&self.entry_point)
|
|
}
|
|
|
|
/// Find a section by id.
|
|
pub fn section(&self, id: &str) -> Option<&ConfigSection> {
|
|
self.sections.iter().find(|s| s.id == id)
|
|
}
|
|
}
|
|
|
|
// ── Auth / keys
|
|
// ───────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum Role {
|
|
Admin,
|
|
Viewer,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct KeyEntry {
|
|
pub role: Role,
|
|
/// Argon2id PHC string — produced by `ontoref-daemon --hash-password <pw>`
|
|
pub hash: String,
|
|
/// Human-readable label identifying which key this is (e.g. "developer",
|
|
/// "agent", "ci"). Recorded in sessions for audit tracing. Optional —
|
|
/// defaults to empty string for keys created before this field existed.
|
|
#[serde(default)]
|
|
pub label: String,
|
|
}
|
|
|
|
/// Result of a successful key verification — role and the label of the
|
|
/// matched key for audit trail purposes.
|
|
#[derive(Debug, Clone)]
|
|
pub struct KeyMatch {
|
|
pub role: Role,
|
|
pub label: String,
|
|
}
|
|
|
|
/// Verify `password` against a slice of `KeyEntry`s using argon2id.
|
|
///
|
|
/// Returns the `KeyMatch` (role + label) of the first matching key, or `None`
|
|
/// if no key matches. Unparseable PHC hashes are logged and skipped.
|
|
pub fn verify_keys_list(keys: &[KeyEntry], password: &str) -> Option<KeyMatch> {
|
|
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
|
for key in keys {
|
|
let Ok(parsed) = PasswordHash::new(&key.hash) else {
|
|
warn!(role = ?key.role, label = %key.label, "skipping key entry with unparseable PHC hash — key is inactive");
|
|
continue;
|
|
};
|
|
if Argon2::default()
|
|
.verify_password(password.as_bytes(), &parsed)
|
|
.is_ok()
|
|
{
|
|
return Some(KeyMatch {
|
|
role: key.role,
|
|
label: key.label.clone(),
|
|
});
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Entry as it arrives from the JSON config pipe (ADR-004).
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct RegistryEntry {
|
|
pub slug: String,
|
|
/// Absolute local path. Empty for push_only projects.
|
|
#[serde(default)]
|
|
pub root: PathBuf,
|
|
/// Import paths for nickel export (local projects only).
|
|
#[serde(default)]
|
|
pub nickel_import_paths: Vec<String>,
|
|
#[serde(default)]
|
|
pub keys: Vec<KeyEntry>,
|
|
/// Git/SSH/HTTPS URL. Informational — daemon never fetches from it.
|
|
#[serde(default)]
|
|
pub remote_url: String,
|
|
/// When true: no local file watch, no NCL import. Project pushes via POST
|
|
/// /sync.
|
|
#[serde(default)]
|
|
pub push_only: bool,
|
|
}
|
|
|
|
/// Per-project runtime state owned by the registry.
|
|
pub struct ProjectContext {
|
|
pub slug: String,
|
|
pub root: PathBuf,
|
|
pub import_path: Option<String>,
|
|
pub cache: Arc<NclCache>,
|
|
pub actors: Arc<ActorRegistry>,
|
|
pub notifications: Arc<NotificationStore>,
|
|
/// Mutable at runtime via `PUT /projects/{slug}/keys`.
|
|
pub keys: RwLock<Vec<KeyEntry>>,
|
|
/// Git/SSH/HTTPS remote URL. Informational.
|
|
pub remote_url: String,
|
|
/// No local file watch, no NCL import — project pushes via POST /sync.
|
|
pub push_only: bool,
|
|
/// Ensures only one `seed_ontology` runs at a time per project.
|
|
/// Prevents concurrent re-seeds caused by rapid filesystem events.
|
|
pub seed_lock: Arc<Semaphore>,
|
|
/// Incremented by 1 after each successful `seed_ontology` completion.
|
|
/// Clients can compare versions to detect stale local state.
|
|
pub ontology_version: Arc<AtomicU64>,
|
|
/// Per-file change counters. Keyed by canonical absolute path; incremented
|
|
/// on every cache invalidation for that file. Consumers compare snapshots
|
|
/// to detect which individual files changed between polls.
|
|
pub file_versions: Arc<DashMap<PathBuf, u64>>,
|
|
/// Config surface metadata loaded from the project's manifest.ncl at
|
|
/// registration time. `None` when the project's manifest has no
|
|
/// `config_surface` field or when the manifest can't be exported.
|
|
pub config_surface: Option<ConfigSurface>,
|
|
}
|
|
|
|
impl ProjectContext {
|
|
pub fn auth_enabled(&self) -> bool {
|
|
!self
|
|
.keys
|
|
.read()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.is_empty()
|
|
}
|
|
|
|
pub fn verify_key(&self, password: &str) -> Option<KeyMatch> {
|
|
let keys = self.keys.read().unwrap_or_else(|e| e.into_inner());
|
|
verify_keys_list(&keys, password)
|
|
}
|
|
|
|
/// Replace the current key set. Returns the number of keys now active.
|
|
pub fn set_keys(&self, new_keys: Vec<KeyEntry>) -> usize {
|
|
let mut guard = self.keys.write().unwrap_or_else(|e| e.into_inner());
|
|
*guard = new_keys;
|
|
guard.len()
|
|
}
|
|
}
|
|
|
|
pub struct ProjectRegistry {
|
|
contexts: DashMap<String, Arc<ProjectContext>>,
|
|
/// Slug of the primary (local) project — always present in `contexts`.
|
|
primary_slug: String,
|
|
stale_actor_timeout: u64,
|
|
max_notifications: usize,
|
|
/// Insertion order preserved from projects.ncl declaration order.
|
|
/// Primary slug is first; additional entries follow in Vec order.
|
|
order: std::sync::RwLock<Vec<String>>,
|
|
}
|
|
|
|
impl ProjectRegistry {
|
|
/// Build a registry with a pre-constructed primary project context, plus
|
|
/// optional additional entries (service mode). The primary context is
|
|
/// inserted under `primary_slug` and accessible via `primary()`.
|
|
///
|
|
/// Returns `(registry, Arc<ProjectContext>)` so the caller can extract
|
|
/// shared `Arc` references (cache, actors, notifications) before consuming
|
|
/// the primary context into the registry.
|
|
pub fn with_primary(
|
|
primary_slug: String,
|
|
primary_ctx: Arc<ProjectContext>,
|
|
additional_entries: Vec<RegistryEntry>,
|
|
stale_actor_timeout: u64,
|
|
max_notifications: usize,
|
|
) -> Self {
|
|
let registry = Self {
|
|
contexts: DashMap::new(),
|
|
primary_slug: primary_slug.clone(),
|
|
stale_actor_timeout,
|
|
max_notifications,
|
|
// order is built from additional_entries; primary is inserted at its
|
|
// natural position (or prepended if it doesn't appear in the list).
|
|
order: std::sync::RwLock::new(Vec::new()),
|
|
};
|
|
registry.contexts.insert(primary_slug.clone(), primary_ctx);
|
|
let mut primary_seen = false;
|
|
|
|
for entry in additional_entries {
|
|
if entry.slug == primary_slug {
|
|
// Primary is already in contexts — just record its natural position.
|
|
primary_seen = true;
|
|
if let Ok(mut ord) = registry.order.write() {
|
|
ord.push(primary_slug.clone());
|
|
}
|
|
continue;
|
|
}
|
|
if entry.push_only {
|
|
let ctx = make_context(ContextSpec {
|
|
slug: entry.slug.clone(),
|
|
root: PathBuf::new(),
|
|
import_path: None,
|
|
keys: entry.keys,
|
|
remote_url: entry.remote_url,
|
|
push_only: true,
|
|
stale_actor_timeout,
|
|
max_notifications,
|
|
ack_required: vec![],
|
|
config_surface: None,
|
|
});
|
|
if let Ok(mut ord) = registry.order.write() {
|
|
ord.push(entry.slug.clone());
|
|
}
|
|
registry.contexts.insert(entry.slug, Arc::new(ctx));
|
|
continue;
|
|
}
|
|
|
|
let root = match entry.root.canonicalize() {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
warn!(
|
|
slug = %entry.slug,
|
|
path = %entry.root.display(),
|
|
error = %e,
|
|
"project root not found — skipping"
|
|
);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let import_path = resolve_import_path(&entry.nickel_import_paths, &root);
|
|
let config_surface = load_config_surface(&root, import_path.as_deref());
|
|
let ctx = make_context(ContextSpec {
|
|
slug: entry.slug.clone(),
|
|
root,
|
|
import_path,
|
|
keys: entry.keys,
|
|
remote_url: entry.remote_url,
|
|
push_only: false,
|
|
stale_actor_timeout,
|
|
max_notifications,
|
|
ack_required: vec![],
|
|
config_surface,
|
|
});
|
|
if let Ok(mut ord) = registry.order.write() {
|
|
ord.push(entry.slug.clone());
|
|
}
|
|
registry.contexts.insert(entry.slug, Arc::new(ctx));
|
|
}
|
|
|
|
// If primary never appeared in additional_entries, prepend it.
|
|
if !primary_seen {
|
|
if let Ok(mut ord) = registry.order.write() {
|
|
ord.insert(0, primary_slug);
|
|
}
|
|
}
|
|
|
|
registry
|
|
}
|
|
|
|
/// The slug of the primary (local) project.
|
|
pub fn primary_slug(&self) -> &str {
|
|
&self.primary_slug
|
|
}
|
|
|
|
/// The primary project context. Panics if not found — invariant guaranteed
|
|
/// by `with_primary` constructor.
|
|
pub fn primary(&self) -> Arc<ProjectContext> {
|
|
self.contexts
|
|
.get(&self.primary_slug)
|
|
.map(|r| Arc::clone(&*r))
|
|
.expect("primary project context missing — registry invariant violated")
|
|
}
|
|
|
|
/// Add a project at runtime (UI-triggered, not from bootstrap).
|
|
pub fn add_project(&self, entry: RegistryEntry) -> anyhow::Result<()> {
|
|
if self.contexts.contains_key(&entry.slug) {
|
|
anyhow::bail!("project '{}' already exists", entry.slug);
|
|
}
|
|
let (root, import_path) = if entry.push_only {
|
|
(PathBuf::new(), None)
|
|
} else {
|
|
let r = entry
|
|
.root
|
|
.canonicalize()
|
|
.map_err(|e| anyhow::anyhow!("project '{}': root path error: {}", entry.slug, e))?;
|
|
let ip = resolve_import_path(&entry.nickel_import_paths, &r);
|
|
(r, ip)
|
|
};
|
|
let config_surface = if entry.push_only {
|
|
None
|
|
} else {
|
|
load_config_surface(&root, import_path.as_deref())
|
|
};
|
|
let ctx = make_context(ContextSpec {
|
|
slug: entry.slug.clone(),
|
|
root,
|
|
import_path,
|
|
keys: entry.keys,
|
|
remote_url: entry.remote_url,
|
|
push_only: entry.push_only,
|
|
stale_actor_timeout: self.stale_actor_timeout,
|
|
max_notifications: self.max_notifications,
|
|
ack_required: vec![],
|
|
config_surface,
|
|
});
|
|
if let Ok(mut ord) = self.order.write() {
|
|
ord.push(entry.slug.clone());
|
|
}
|
|
self.contexts.insert(entry.slug, Arc::new(ctx));
|
|
Ok(())
|
|
}
|
|
|
|
/// Remove a project by slug.
|
|
pub fn remove_project(&self, slug: &str) {
|
|
self.contexts.remove(slug);
|
|
if let Ok(mut ord) = self.order.write() {
|
|
ord.retain(|s| s != slug);
|
|
}
|
|
}
|
|
|
|
pub fn get(&self, slug: &str) -> Option<Arc<ProjectContext>> {
|
|
self.contexts.get(slug).map(|r| Arc::clone(&*r))
|
|
}
|
|
|
|
/// Returns all project contexts in declaration order (projects.ncl order).
|
|
/// Primary project is always first.
|
|
pub fn all(&self) -> Vec<Arc<ProjectContext>> {
|
|
let order = self.order.read().unwrap_or_else(|e| e.into_inner());
|
|
let mut list: Vec<Arc<ProjectContext>> = order
|
|
.iter()
|
|
.filter_map(|slug| self.contexts.get(slug).map(|r| Arc::clone(&*r)))
|
|
.collect();
|
|
// Append any contexts not in order (defensive — should not happen).
|
|
let ordered_slugs: std::collections::HashSet<&str> =
|
|
order.iter().map(|s| s.as_str()).collect();
|
|
let mut extras: Vec<_> = self
|
|
.contexts
|
|
.iter()
|
|
.filter(|r| !ordered_slugs.contains(r.key().as_str()))
|
|
.map(|r| Arc::clone(&*r))
|
|
.collect();
|
|
extras.sort_by(|a, b| a.slug.cmp(&b.slug));
|
|
list.extend(extras);
|
|
list
|
|
}
|
|
|
|
pub fn count(&self) -> usize {
|
|
self.contexts.len()
|
|
}
|
|
|
|
/// Replace the key set for a registered project.
|
|
/// Returns `None` if the slug is not found.
|
|
pub fn update_keys(&self, slug: &str, keys: Vec<KeyEntry>) -> Option<usize> {
|
|
self.contexts.get(slug).map(|ctx| ctx.set_keys(keys))
|
|
}
|
|
}
|
|
|
|
/// Resolve nickel_import_paths entries into a colon-separated
|
|
/// NICKEL_IMPORT_PATH string. Relative paths are resolved against the project
|
|
/// root.
|
|
fn resolve_import_path(paths: &[String], root: &Path) -> Option<String> {
|
|
let joined = paths
|
|
.iter()
|
|
.map(|p| {
|
|
let candidate = Path::new(p);
|
|
if candidate.is_absolute() {
|
|
p.clone()
|
|
} else {
|
|
root.join(candidate).display().to_string()
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(":");
|
|
if joined.is_empty() {
|
|
None
|
|
} else {
|
|
Some(joined)
|
|
}
|
|
}
|
|
|
|
pub struct ContextSpec {
|
|
pub slug: String,
|
|
pub root: PathBuf,
|
|
pub import_path: Option<String>,
|
|
pub keys: Vec<KeyEntry>,
|
|
pub remote_url: String,
|
|
pub push_only: bool,
|
|
pub stale_actor_timeout: u64,
|
|
pub max_notifications: usize,
|
|
/// Directories that require notification acknowledgment.
|
|
pub ack_required: Vec<String>,
|
|
/// Pre-loaded config surface from the project's manifest.ncl.
|
|
/// Pass `None` for push_only projects or when the manifest has no
|
|
/// `config_surface` field.
|
|
pub config_surface: Option<ConfigSurface>,
|
|
}
|
|
|
|
/// Attempt to load `ConfigSurface` from a project's `manifest.ncl`
|
|
/// synchronously.
|
|
///
|
|
/// Runs `nickel export` on `.ontology/manifest.ncl` and deserialises the
|
|
/// `config_surface` key. Returns `None` when:
|
|
/// - the manifest file doesn't exist
|
|
/// - the manifest has no `config_surface` field
|
|
/// - nickel export or deserialisation fails (logged at warn level)
|
|
pub fn load_config_surface(root: &Path, import_path: Option<&str>) -> Option<ConfigSurface> {
|
|
let manifest = root.join(".ontology").join("manifest.ncl");
|
|
if !manifest.exists() {
|
|
return None;
|
|
}
|
|
|
|
let mut cmd = std::process::Command::new("nickel");
|
|
cmd.args(["export", "--format", "json"])
|
|
.arg(&manifest)
|
|
.current_dir(root);
|
|
if let Some(ip) = import_path {
|
|
cmd.env("NICKEL_IMPORT_PATH", ip);
|
|
}
|
|
let output = cmd.output().ok()?;
|
|
|
|
if !output.status.success() {
|
|
warn!(
|
|
path = %manifest.display(),
|
|
stderr = %String::from_utf8_lossy(&output.stderr),
|
|
"nickel export of manifest.ncl failed — config_surface not loaded"
|
|
);
|
|
return None;
|
|
}
|
|
|
|
let json: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
|
|
let surface_val = json.get("config_surface")?;
|
|
|
|
// Deserialise into an intermediate form that matches the NCL schema,
|
|
// then convert to the canonical ConfigSurface.
|
|
#[derive(Deserialize)]
|
|
struct NclConfigConsumer {
|
|
id: String,
|
|
kind: String,
|
|
#[serde(default, rename = "ref")]
|
|
reference: String,
|
|
#[serde(default)]
|
|
fields: Vec<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct NclConfigSection {
|
|
id: String,
|
|
file: String,
|
|
#[serde(default)]
|
|
contract: String,
|
|
#[serde(default)]
|
|
description: String,
|
|
#[serde(default)]
|
|
rationale: String,
|
|
#[serde(default = "default_true")]
|
|
mutable: bool,
|
|
#[serde(default)]
|
|
consumers: Vec<NclConfigConsumer>,
|
|
}
|
|
|
|
fn default_true() -> bool {
|
|
true
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct NclConfigSurface {
|
|
config_root: String,
|
|
#[serde(default = "default_config_ncl")]
|
|
entry_point: String,
|
|
#[serde(default = "default_ncl_merge")]
|
|
kind: String,
|
|
#[serde(default)]
|
|
contracts_path: String,
|
|
#[serde(default)]
|
|
overrides_dir: String,
|
|
#[serde(default)]
|
|
sections: Vec<NclConfigSection>,
|
|
}
|
|
|
|
fn default_config_ncl() -> String {
|
|
"config.ncl".to_string()
|
|
}
|
|
fn default_ncl_merge() -> String {
|
|
"NclMerge".to_string()
|
|
}
|
|
|
|
let ncl: NclConfigSurface = match serde_json::from_value(surface_val.clone()) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
warn!(error = %e, "failed to deserialise config_surface from manifest.ncl");
|
|
return None;
|
|
}
|
|
};
|
|
|
|
let kind = match ncl.kind.as_str() {
|
|
"TypeDialog" => ConfigKind::TypeDialog,
|
|
"SingleFile" => ConfigKind::SingleFile,
|
|
_ => ConfigKind::NclMerge,
|
|
};
|
|
|
|
let config_root = root.join(&ncl.config_root);
|
|
let overrides_dir = if ncl.overrides_dir.is_empty() {
|
|
PathBuf::new()
|
|
} else {
|
|
root.join(&ncl.overrides_dir)
|
|
};
|
|
|
|
let sections = ncl
|
|
.sections
|
|
.into_iter()
|
|
.map(|s| {
|
|
let consumers = s
|
|
.consumers
|
|
.into_iter()
|
|
.map(|c| {
|
|
let consumer_kind = match c.kind.as_str() {
|
|
"NuScript" => ConsumerKind::NuScript,
|
|
"CiPipeline" => ConsumerKind::CiPipeline,
|
|
"External" => ConsumerKind::External,
|
|
_ => ConsumerKind::RustStruct,
|
|
};
|
|
ConfigConsumer {
|
|
id: c.id,
|
|
kind: consumer_kind,
|
|
reference: c.reference,
|
|
fields: c.fields,
|
|
}
|
|
})
|
|
.collect();
|
|
ConfigSection {
|
|
id: s.id,
|
|
file: s.file,
|
|
contract: s.contract,
|
|
description: s.description,
|
|
rationale: s.rationale,
|
|
mutable: s.mutable,
|
|
consumers,
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
Some(ConfigSurface {
|
|
config_root,
|
|
entry_point: ncl.entry_point,
|
|
kind,
|
|
contracts_path: ncl.contracts_path,
|
|
overrides_dir,
|
|
sections,
|
|
})
|
|
}
|
|
|
|
pub fn make_context(spec: ContextSpec) -> ProjectContext {
|
|
let sessions_dir = spec.root.join(".ontoref").join("sessions");
|
|
let actors =
|
|
Arc::new(ActorRegistry::new(spec.stale_actor_timeout).with_persist_dir(sessions_dir));
|
|
actors.load_persisted();
|
|
|
|
let ack_dirs = if spec.ack_required.is_empty() {
|
|
vec![".ontology".into(), "adrs".into()]
|
|
} else {
|
|
spec.ack_required
|
|
};
|
|
|
|
ProjectContext {
|
|
slug: spec.slug,
|
|
root: spec.root,
|
|
import_path: spec.import_path,
|
|
cache: Arc::new(NclCache::new()),
|
|
actors,
|
|
notifications: Arc::new(NotificationStore::new(spec.max_notifications, ack_dirs)),
|
|
keys: RwLock::new(spec.keys),
|
|
remote_url: spec.remote_url,
|
|
push_only: spec.push_only,
|
|
seed_lock: Arc::new(Semaphore::new(1)),
|
|
ontology_version: Arc::new(AtomicU64::new(0)),
|
|
file_versions: Arc::new(DashMap::new()),
|
|
config_surface: spec.config_surface,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::Arc;
|
|
|
|
use super::*;
|
|
|
|
fn phc_hash(password: &str) -> String {
|
|
use argon2::password_hash::{rand_core::OsRng, SaltString};
|
|
use argon2::{Argon2, PasswordHasher};
|
|
let salt = SaltString::generate(&mut OsRng);
|
|
Argon2::default()
|
|
.hash_password(password.as_bytes(), &salt)
|
|
.unwrap()
|
|
.to_string()
|
|
}
|
|
|
|
fn admin_entry(password: &str) -> KeyEntry {
|
|
KeyEntry {
|
|
role: Role::Admin,
|
|
hash: phc_hash(password),
|
|
label: String::new(),
|
|
}
|
|
}
|
|
|
|
fn viewer_entry(password: &str) -> KeyEntry {
|
|
KeyEntry {
|
|
role: Role::Viewer,
|
|
hash: phc_hash(password),
|
|
label: String::new(),
|
|
}
|
|
}
|
|
|
|
// ── verify_keys_list ──────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn correct_password_returns_role() {
|
|
let keys = vec![admin_entry("secret")];
|
|
assert_eq!(
|
|
verify_keys_list(&keys, "secret").map(|m| m.role),
|
|
Some(Role::Admin)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wrong_password_returns_none() {
|
|
let keys = vec![admin_entry("secret")];
|
|
assert!(verify_keys_list(&keys, "wrong").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn empty_keys_returns_none() {
|
|
assert!(verify_keys_list(&[], "anything").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn returns_first_matching_role() {
|
|
let keys = vec![viewer_entry("pw"), admin_entry("pw")];
|
|
// Viewer comes first — that role wins.
|
|
assert_eq!(
|
|
verify_keys_list(&keys, "pw").map(|m| m.role),
|
|
Some(Role::Viewer)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_phc_hash_is_skipped_not_panicked() {
|
|
let keys = vec![
|
|
KeyEntry {
|
|
role: Role::Admin,
|
|
hash: "not-a-phc-string".to_string(),
|
|
label: String::new(),
|
|
},
|
|
admin_entry("valid"),
|
|
];
|
|
// Bad entry skipped; valid entry matches.
|
|
assert_eq!(
|
|
verify_keys_list(&keys, "valid").map(|m| m.role),
|
|
Some(Role::Admin)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_phc_only_returns_none() {
|
|
let keys = vec![KeyEntry {
|
|
role: Role::Admin,
|
|
hash: "garbage".to_string(),
|
|
label: String::new(),
|
|
}];
|
|
assert!(verify_keys_list(&keys, "anything").is_none());
|
|
}
|
|
|
|
// ── resolve_import_path ───────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn empty_paths_returns_none() {
|
|
assert_eq!(resolve_import_path(&[], Path::new("/root")), None);
|
|
}
|
|
|
|
#[test]
|
|
fn absolute_path_returned_as_is() {
|
|
let result = resolve_import_path(&["/abs/path".to_string()], Path::new("/root"));
|
|
assert_eq!(result, Some("/abs/path".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn relative_path_joined_with_root() {
|
|
let result = resolve_import_path(&["lib".to_string()], Path::new("/root"));
|
|
assert_eq!(result, Some("/root/lib".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn multiple_paths_colon_separated() {
|
|
let result =
|
|
resolve_import_path(&["/abs".to_string(), "rel".to_string()], Path::new("/root"));
|
|
assert_eq!(result, Some("/abs:/root/rel".to_string()));
|
|
}
|
|
|
|
// ── with_primary slug collision ───────────────────────────────────────
|
|
|
|
fn minimal_ctx(slug: &str) -> Arc<ProjectContext> {
|
|
Arc::new(make_context(ContextSpec {
|
|
slug: slug.to_string(),
|
|
root: PathBuf::new(),
|
|
import_path: None,
|
|
keys: vec![],
|
|
remote_url: String::new(),
|
|
push_only: true,
|
|
stale_actor_timeout: 300,
|
|
max_notifications: 64,
|
|
ack_required: vec![],
|
|
config_surface: None,
|
|
}))
|
|
}
|
|
|
|
#[test]
|
|
fn primary_is_accessible_after_construction() {
|
|
let registry =
|
|
ProjectRegistry::with_primary("main".to_string(), minimal_ctx("main"), vec![], 300, 64);
|
|
assert_eq!(registry.primary().slug, "main");
|
|
assert_eq!(registry.primary_slug(), "main");
|
|
}
|
|
|
|
#[test]
|
|
fn slug_collision_does_not_overwrite_primary() {
|
|
let primary = minimal_ctx("shared");
|
|
let primary_ptr = Arc::as_ptr(&primary);
|
|
|
|
let colliding = RegistryEntry {
|
|
slug: "shared".to_string(),
|
|
root: PathBuf::new(),
|
|
nickel_import_paths: vec![],
|
|
keys: vec![],
|
|
remote_url: String::new(),
|
|
push_only: true,
|
|
};
|
|
|
|
let registry =
|
|
ProjectRegistry::with_primary("shared".to_string(), primary, vec![colliding], 300, 64);
|
|
|
|
// The primary context pointer must survive — not replaced by the colliding
|
|
// entry.
|
|
assert_eq!(Arc::as_ptr(®istry.primary()), primary_ptr);
|
|
assert_eq!(registry.count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn additional_non_colliding_entry_added() {
|
|
let registry = ProjectRegistry::with_primary(
|
|
"primary".to_string(),
|
|
minimal_ctx("primary"),
|
|
vec![RegistryEntry {
|
|
slug: "secondary".to_string(),
|
|
root: PathBuf::new(),
|
|
nickel_import_paths: vec![],
|
|
keys: vec![],
|
|
remote_url: String::new(),
|
|
push_only: true,
|
|
}],
|
|
300,
|
|
64,
|
|
);
|
|
assert_eq!(registry.count(), 2);
|
|
assert!(registry.get("secondary").is_some());
|
|
}
|
|
}
|