use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
use ikigai_core::{
build, ArgRef, ArgSpec, Bindings, DeclarationError, Description, Door, Endpoint, EndpointSpace,
Error, Exact, Fallback, Invocation, Iri, Kernel, MatchKind, Registry, ReprType, Representation,
Request, Resolution, Result, Scope, Space, SpaceKind, Topology, UriTemplate, Verb,
};
pub const CONFIG_KEY: &str = "arrangement";
pub const FLAG: &str = "--arrangement";
pub const RESOURCE: &str = "urn:iki:host:arrangement";
pub(crate) const PROBE: &str = "ikigai-arrangement-probe";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ArrangementSource {
Flag,
Config,
}
impl ArrangementSource {
pub fn as_str(self) -> &'static str {
match self {
ArrangementSource::Flag => "flag",
ArrangementSource::Config => "config",
}
}
}
#[derive(Clone, Debug)]
pub struct Declared {
pub path: PathBuf,
pub source: ArrangementSource,
pub topology: Topology,
}
static FLAG_PATH: OnceLock<PathBuf> = OnceLock::new();
static ACTIVE: OnceLock<Declared> = OnceLock::new();
pub fn set_arrangement_path(path: impl Into<PathBuf>) {
let path = path.into();
let path = if path.is_relative() {
std::env::current_dir()
.map(|cwd| cwd.join(&path))
.unwrap_or(path)
} else {
path
};
let _ = FLAG_PATH.set(path);
}
pub fn arrangement_flag() -> Option<&'static Path> {
FLAG_PATH.get().map(PathBuf::as_path)
}
pub fn active() -> Option<&'static Declared> {
ACTIVE.get()
}
pub fn arm() -> std::result::Result<Option<&'static Declared>, String> {
if let Some(declared) = ACTIVE.get() {
return Ok(Some(declared));
}
let Some((path, source)) = named() else {
return Ok(None);
};
let turtle = read_declaration(&path).map_err(|e| {
format!(
"the declared arrangement `{}` cannot be read: {e}",
path.display()
)
})?;
let topology = Topology::from_turtle(&turtle)
.map_err(|e| format!("the declared arrangement `{}`: {e}", path.display()))?;
let _ = ACTIVE.set(Declared {
path,
source,
topology,
});
Ok(ACTIVE.get())
}
fn named() -> Option<(PathBuf, ArrangementSource)> {
if let Some(path) = FLAG_PATH.get() {
return Some((path.clone(), ArrangementSource::Flag));
}
let value = crate::config::get(&format!("{}.{CONFIG_KEY}", crate::instance_name()))
.or_else(|| crate::config::get(CONFIG_KEY))?;
Some((config_relative(&value), ArrangementSource::Config))
}
fn config_relative(value: &str) -> PathBuf {
if let Some(rest) = value.strip_prefix("~/") {
if let Some(home) = std::env::var_os("HOME") {
return PathBuf::from(home).join(rest);
}
}
let path = PathBuf::from(value);
match crate::config::config_home() {
Some(home) if path.is_relative() => home.join(path),
_ => path,
}
}
pub(crate) fn read_declaration(path: &Path) -> std::result::Result<String, String> {
let dir = path
.parent()
.filter(|dir| !dir.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let file = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| "the path names no file".to_string())?;
let name = Iri::parse(format!("urn:file:{file}"))
.map_err(|e| format!("the file name is not IRI-safe: {e}"))?;
let root: Arc<dyn Space> = Arc::new(Fallback::new(vec![
Arc::new(EndpointSpace::new().bind(
UriTemplate::parse(ikigai_fs::FILE_TEMPLATE).expect("FILE_TEMPLATE is valid"),
ikigai_fs::FileEndpoint::new(dir),
)) as Arc<dyn Space>,
Arc::new(ikigai_rdf::space()) as Arc<dyn Space>,
Arc::new(ikigai_jsonld::space()) as Arc<dyn Space>,
Arc::new(ikigai_sexpr::space()) as Arc<dyn Space>,
]));
let kernel = Kernel::new(Arc::clone(&root));
let issue = |request: Request| {
ikigai_resolve::Resolver::issue(&kernel, request)
.map(|(representation, _)| representation)
.map_err(|e| e.to_string())
};
let read = issue(Request::new(Verb::Source, name))?;
const TURTLE: &str = "text/turtle";
let mut current = read;
if current.repr_type.media_type != TURTLE {
let from = current.repr_type.media_type.clone();
let plan =
ikigai_core::select_transreptor(root.as_ref(), &from, TURTLE).ok_or_else(|| {
format!("it is `{from}`, and nothing here transrepts that to {TURTLE} losslessly")
})?;
for step in plan {
let iri = Iri::parse(&step.endpoint).map_err(|e| e.to_string())?;
let endpoint = step.endpoint.clone();
let request = Request::new(Verb::Source, iri)
.with_arg("content", ArgRef::Inline(current.bytes))
.with_arg("as", ArgRef::Inline(step.to.into_bytes()));
current = issue(request)
.map_err(|e| format!("it is `{from}`, and <{endpoint}> refused it: {e}"))?;
}
}
String::from_utf8(current.bytes).map_err(|_| format!("the {TURTLE} is not UTF-8"))
}
pub(crate) struct Harvest {
pub registry: Registry,
pub ambiguous: BTreeMap<String, Vec<String>>,
pub unreached: Vec<(String, String)>,
}
type Bound = (String, Arc<dyn Endpoint>);
pub(crate) fn harvest(members: &[Arc<dyn Space>]) -> Harvest {
let mut found: BTreeMap<String, Vec<Bound>> = BTreeMap::new();
let mut unreached = Vec::new();
for member in members {
let mut doors = Vec::new();
collect_doors(&member.topology(), &mut doors);
for door in doors {
match probe(member.as_ref(), &door) {
Some(endpoint) => found
.entry(door.endpoint.clone())
.or_default()
.push((door.pattern.clone(), endpoint)),
None => unreached.push((door.pattern.clone(), door.endpoint.clone())),
}
}
}
let mut registry = Registry::new();
let mut ambiguous = BTreeMap::new();
for (name, bound) in found {
let first = Arc::clone(&bound[0].1);
if bound
.iter()
.all(|(_, endpoint)| Arc::ptr_eq(endpoint, &first))
{
let _ = registry.register(first);
} else {
ambiguous.insert(
name,
bound.into_iter().map(|(pattern, _)| pattern).collect(),
);
}
}
Harvest {
registry,
ambiguous,
unreached,
}
}
fn collect_doors(node: &Topology, doors: &mut Vec<Door>) {
if let SpaceKind::EndpointSpace { doors: here } = &node.kind {
doors.extend(here.iter().cloned());
}
for child in &node.children {
collect_doors(child, doors);
}
}
fn probe(space: &dyn Space, door: &Door) -> Option<Arc<dyn Endpoint>> {
if door.confined.is_some() {
return None;
}
let name = match door.kind {
MatchKind::Exact => door.pattern.clone(),
MatchKind::Template => {
let template = UriTemplate::parse(&door.pattern).ok()?;
let mut bindings = Bindings::new();
for var in template.variables() {
bindings.insert(var, PROBE);
}
template.expand(&bindings)?
}
_ => return None,
};
let request = Request::new(Verb::Meta, Iri::parse(name).ok()?);
match space.resolve(&request, &Scope::empty()) {
Resolution::Hit(resolved) if resolved.endpoint.name() == door.endpoint => {
Some(resolved.endpoint)
}
_ => None,
}
}
pub(crate) fn arrange(
harvest: &Harvest,
declaration: &Topology,
) -> std::result::Result<Arc<dyn Space>, String> {
build(declaration, &harvest.registry).map_err(|e| explain(&e, harvest))
}
fn explain(error: &DeclarationError, harvest: &Harvest) -> String {
if let DeclarationError::UnknownEndpoint { door, id, .. } = error {
if let Some(patterns) = harvest.ambiguous.get(id) {
return format!(
"<{door}> binds `{id}`, which names {} different endpoints in this host (bound \
at {}): a door binds by name, so the name cannot say which one. Declare the \
arrangement without it; the fix is for each endpoint to carry its own name",
patterns.len(),
quoted(patterns)
);
}
let at: Vec<String> = harvest
.unreached
.iter()
.filter(|(_, name)| name == id)
.map(|(pattern, _)| pattern.clone())
.collect();
if !at.is_empty() {
return format!(
"<{door}> binds `{id}`, which this host binds (at {}) but could not recover from \
its own arrangement: that door's pattern resolves to another endpoint in its \
space, or is a kind core cannot rebuild",
quoted(&at)
);
}
}
error.to_string()
}
fn quoted(patterns: &[String]) -> String {
patterns
.iter()
.map(|pattern| format!("`{pattern}`"))
.collect::<Vec<_>>()
.join(", ")
}
pub const MEDIA_ARRANGEMENT: &str = ikigai_sexpr::arrangement::MEDIA_ARRANGEMENT;
const MEDIA_TURTLE: &str = "text/turtle";
pub(crate) struct Dump {
header: Vec<String>,
topology: Topology,
}
impl Dump {
pub(crate) fn turtle(&self) -> std::result::Result<String, String> {
self.topology
.try_to_turtle()
.map(|body| self.with_header("#", &body))
.map_err(|e| e.to_string())
}
pub(crate) fn sexpr(&self) -> std::result::Result<String, String> {
ikigai_sexpr::topology_to_arrangement(&self.topology)
.map(|body| self.with_header(";;", &body))
.map_err(|e| e.to_string())
}
fn with_header(&self, comment: &str, body: &str) -> String {
let mut out = String::new();
for line in &self.header {
out.push_str(comment);
out.push(' ');
out.push_str(line);
out.push('\n');
}
out.push('\n');
out.push_str(body);
out
}
}
pub(crate) fn dump(arrangement: &Topology, origin: &str, harvest: &Harvest) -> Dump {
let mut header = vec![
format!("The arrangement this host built its local root from: {origin}."),
format!("Save it, edit it, and start from it: `ikigai {FLAG} <file>`, or"),
format!("`{CONFIG_KEY} = \"<file>\"` in the config home's config.toml."),
"Layered around it, and not part of it: the alias table, config-home mounts, the demo"
.to_string(),
format!("runbook (gated by urn:host:demo) and {RESOURCE} itself."),
];
let mut used = Vec::new();
collect_doors(arrangement, &mut used);
let mut warned = std::collections::BTreeSet::new();
for door in &used {
if let Some(patterns) = harvest.ambiguous.get(&door.endpoint) {
if warned.insert(door.endpoint.clone()) {
header.push(format!(
"⚠ Not declarable as it stands: `{}` names {} different endpoints here ({}).",
door.endpoint,
patterns.len(),
quoted(patterns)
));
}
}
}
Dump {
header,
topology: arrangement.clone(),
}
}
pub(crate) struct ArrangementEndpoint {
dump: Dump,
}
impl ArrangementEndpoint {
pub(crate) fn new(dump: Dump) -> Self {
ArrangementEndpoint { dump }
}
}
#[async_trait::async_trait]
impl Endpoint for ArrangementEndpoint {
async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
let face = match inv.request.args.get("as") {
None => MEDIA_TURTLE.to_string(),
Some(_) => inv.inline_str("as")?.trim().to_string(),
};
let bytes = match face.as_str() {
MEDIA_TURTLE => self.dump.turtle().map_err(|detail| {
Error::Conflict(format!(
"this host's arrangement cannot be written as a declaration: {detail}"
))
})?,
MEDIA_ARRANGEMENT => self.dump.sexpr().map_err(|detail| {
Error::Endpoint(format!(
"this host's arrangement cannot be written as {MEDIA_ARRANGEMENT}: {detail}"
))
})?,
other => {
return Err(Error::InvalidArgument {
name: "as".to_string(),
detail: format!(
"`{other}` is not a face of {RESOURCE}; it answers {MEDIA_TURTLE} \
(the default) or {MEDIA_ARRANGEMENT}"
),
})
}
};
Ok(Representation::new(
ReprType::new(face).with_param("charset", "utf-8"),
bytes.into_bytes(),
)
.cacheable())
}
fn name(&self) -> &str {
"host-arrangement"
}
fn describe(&self) -> Description {
Description::new("host-arrangement")
.title("The host's arrangement")
.summary(
"The arrangement this host built its local root from, as a declaration: the \
root node of urn:kernel:topology without the layers the host adds around it. \
Save it, edit it, and start a host from it with --arrangement <file>. Turtle \
by default; as=text/x-ikigai-arrangement writes it as an s-expression.",
)
.verb(Verb::Source)
.verb(Verb::Meta)
.input(
ArgSpec::new("as")
.summary("the face to answer: Turtle, or the s-expression an `.arrangement` file holds")
.class(XSD_STRING)
.one_of([MEDIA_TURTLE, MEDIA_ARRANGEMENT])
.default_value(MEDIA_TURTLE)
.optional(),
)
.output("text/turtle;charset=utf-8")
.output("text/x-ikigai-arrangement;charset=utf-8")
}
}
const XSD_STRING: &str = "http://www.w3.org/2001/XMLSchema#string";
pub(crate) fn dump_space(dump: Dump) -> EndpointSpace {
EndpointSpace::new().bind(Exact::new(RESOURCE), ArrangementEndpoint::new(dump))
}
#[cfg(test)]
mod tests {
use super::*;
use ikigai_core::FnEndpoint;
fn endpoint(name: &'static str) -> Arc<dyn Endpoint> {
Arc::new(FnEndpoint::new(name, move |_| {
Ok(Representation::new(
ReprType::new("text/plain"),
name.as_bytes().to_vec(),
))
}))
}
#[test]
fn a_shared_endpoint_registers_once_and_a_shared_name_is_ambiguous() {
let one = endpoint("one");
let members: Vec<Arc<dyn Space>> = vec![
Arc::new(
EndpointSpace::new()
.bind_arc(Exact::new("urn:t:one"), Arc::clone(&one))
.bind_arc(UriTemplate::parse("urn:t:one:{x}").unwrap(), one)
.bind_arc(Exact::new("urn:t:a"), endpoint("same")),
),
Arc::new(EndpointSpace::new().bind_arc(Exact::new("urn:t:b"), endpoint("same"))),
];
let harvest = harvest(&members);
assert_eq!(harvest.registry.ids().collect::<Vec<_>>(), vec!["one"]);
assert_eq!(
harvest.ambiguous.get("same"),
Some(&vec!["urn:t:a".to_string(), "urn:t:b".to_string()])
);
assert!(harvest.unreached.is_empty());
}
#[test]
fn a_shadowed_door_is_unreached() {
let members: Vec<Arc<dyn Space>> = vec![Arc::new(
EndpointSpace::new()
.bind_arc(UriTemplate::parse("urn:t:{x}").unwrap(), endpoint("wide"))
.bind_arc(Exact::new("urn:t:narrow"), endpoint("narrow")),
)];
let harvest = harvest(&members);
assert_eq!(
harvest.unreached,
vec![("urn:t:narrow".to_string(), "narrow".to_string())]
);
assert_eq!(harvest.registry.ids().collect::<Vec<_>>(), vec!["wide"]);
}
#[test]
fn an_ambiguous_name_is_refused_with_the_doors_it_is_bound_at() {
let members: Vec<Arc<dyn Space>> = vec![
Arc::new(EndpointSpace::new().bind_arc(Exact::new("urn:t:a"), endpoint("same"))),
Arc::new(EndpointSpace::new().bind_arc(Exact::new("urn:t:b"), endpoint("same"))),
];
let harvest = harvest(&members);
let declaration = Fallback::new(members).topology();
let Err(refused) = arrange(&harvest, &declaration) else {
panic!("an ambiguous name must not build")
};
assert!(
refused.contains("`same`")
&& refused.contains("2 different endpoints")
&& refused.contains("`urn:t:a`, `urn:t:b`"),
"{refused}"
);
assert!(
refused.contains("<urn:ikigai:space:_:"),
"names the door: {refused}"
);
}
#[test]
fn the_dump_parses_back_to_the_arrangement() {
let members: Vec<Arc<dyn Space>> = vec![
Arc::new(EndpointSpace::new().bind_arc(Exact::new("urn:t:a"), endpoint("same"))),
Arc::new(EndpointSpace::new().bind_arc(Exact::new("urn:t:b"), endpoint("same"))),
];
let harvest = harvest(&members);
let arrangement = Fallback::new(members).topology();
let turtle = dump(&arrangement, "the built-in default", &harvest)
.turtle()
.unwrap();
assert!(
turtle.contains("# ⚠ Not declarable as it stands: `same`"),
"{turtle}"
);
assert_eq!(Topology::from_turtle(&turtle).unwrap(), arrangement);
}
#[test]
fn the_dump_has_an_s_expression_face_that_reads_back() {
let members: Vec<Arc<dyn Space>> = vec![
Arc::new(EndpointSpace::new().bind_arc(Exact::new("urn:t:a"), endpoint("a"))),
Arc::new(EndpointSpace::new().bind_arc(Exact::new("urn:t:b"), endpoint("b"))),
];
let harvest = harvest(&members);
let arrangement = Fallback::new(members).topology();
let dump = dump(&arrangement, "the built-in default", &harvest);
let sexpr = dump.sexpr().unwrap();
assert!(
sexpr.starts_with(";; The arrangement this host built its local root from"),
"{sexpr}"
);
assert!(sexpr.contains("(door \"urn:t:a\" a)"), "{sexpr}");
assert_eq!(
ikigai_sexpr::arrangement_to_topology(&sexpr).unwrap(),
arrangement
);
assert_eq!(
Topology::from_turtle(&dump.turtle().unwrap()).unwrap(),
ikigai_sexpr::arrangement_to_topology(&sexpr).unwrap()
);
}
#[test]
fn a_declaration_is_read_through_a_kernel() {
let dir = std::env::temp_dir().join(format!("iki-arr-read-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("root.ttl");
std::fs::write(&path, "@prefix ik: <https://ikigai-rs.dev/ns#> .\n").unwrap();
assert!(read_declaration(&path).unwrap().contains("@prefix ik:"));
assert!(read_declaration(&dir.join("absent.ttl")).is_err());
let leaf = Topology::new(SpaceKind::EndpointSpace {
doors: vec![Door::new("urn:t:a", MatchKind::Exact, "a")],
});
let (rdf, ik) = (
"http://www.w3.org/1999/02/22-rdf-syntax-ns#",
"https://ikigai-rs.dev/ns#",
);
let (s, list, door) = (
"urn:ikigai:space:_:1",
"urn:ikigai:space:_:1:doors:1",
"urn:ikigai:space:_:1:door:1",
);
let triples = [
format!("<{s}> <{rdf}type> <{ik}EndpointSpace> ."),
format!("<{s}> <{ik}pattern> \"urn:t:a\" ."),
format!("<{s}> <{ik}doors> <{list}> ."),
format!("<{list}> <{rdf}first> <{door}> ."),
format!("<{list}> <{rdf}rest> <{rdf}nil> ."),
format!("<{door}> <{rdf}type> <{ik}Door> ."),
format!("<{door}> <{ik}pattern> \"urn:t:a\" ."),
format!("<{door}> <{ik}matchKind> \"exact\" ."),
format!("<{door}> <{ik}endpointName> \"a\" ."),
];
let nt = dir.join("root.nt");
std::fs::write(&nt, triples.join("\n")).unwrap();
let turtle = read_declaration(&nt).unwrap();
assert_eq!(Topology::from_turtle(&turtle).unwrap(), leaf);
let file = dir.join("root.arrangement");
std::fs::write(&file, ";; one door\n(endpoints (door \"urn:t:a\" a))\n").unwrap();
let turtle = read_declaration(&file).unwrap();
assert_eq!(Topology::from_turtle(&turtle).unwrap(), leaf);
std::fs::write(&file, "(endpoints (portal \"urn:t:a\" a))").unwrap();
let refused = read_declaration(&file).unwrap_err();
assert!(
refused.contains("<urn:sexpr:arrangement-to-rdf> refused it")
&& refused.contains("door 1")
&& refused.contains("(portal …)"),
"{refused}"
);
let _ = std::fs::remove_dir_all(&dir);
}
}