use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
use ikigai_core::{
Description, EndpointSpace, Error, Exact, Fallback, FnEndpoint, Invocation, Kernel,
MetaRenderer, ReprType, Representation, Request, Resolution, Result, Scope, Space, SpaceEntry,
SystemClock, UriTemplate, Verb,
};
use ikigai_scheduler::Scheduler;
use ikigai_vocab::TurtleRenderer;
use notify::{RecursiveMode, Watcher};
struct CliRenderer;
impl MetaRenderer for CliRenderer {
fn render(&self, description: &Description, target: &ReprType) -> Result<Representation> {
if target.media_type == "application/json" {
let json = serde_json::to_vec(description)
.map_err(|e| Error::Endpoint(format!("describe as json: {e}")))?;
return Ok(Representation::new(ReprType::new("application/json"), json));
}
TurtleRenderer.render(description, target)
}
}
fn page_impl(_inv: &Invocation<'_>) -> Result<Representation> {
let body = "ikigai compose demo — one pull, recursively assembled\n\n \
toUpper : $a{urn:fn:toUpper?in=\"resource oriented computing\"}\n \
wrap : $a{urn:demo:wrap?text=hello}\n \
greet : $a{urn:demo:greet?greeting=Hi&name=World}\n \
nested : $a{urn:data:about}\n\n\
literal marker (escaped, not expanded): $$a{urn:fn:toUpper?in=x}\n";
Ok(Representation::new(
ReprType::new("text/plain").with_param("charset", "utf-8"),
body.as_bytes().to_vec(),
)
.cacheable())
}
fn page() -> FnEndpoint {
FnEndpoint::new("page", page_impl).with_description(
Description::new("page")
.title("Demo page")
.summary("A compose shape: a text template with `$a{<iri>}` transclusion markers.")
.verb(Verb::Source)
.verb(Verb::Meta)
.output("text/plain;charset=utf-8"),
)
}
fn about_impl(_inv: &Invocation<'_>) -> Result<Representation> {
let body = "a shape within a shape: \
$a{urn:fn:toUpper?in=\"composed within a composed shape\"}";
Ok(Representation::new(
ReprType::new("text/plain").with_param("charset", "utf-8"),
body.as_bytes().to_vec(),
)
.cacheable())
}
fn about() -> FnEndpoint {
FnEndpoint::new("about", about_impl).with_description(
Description::new("about")
.title("About (nested shape)")
.summary("A compose shape the demo page transcludes, which itself transcludes another resource.")
.verb(Verb::Source)
.verb(Verb::Meta)
.output("text/plain;charset=utf-8"),
)
}
fn host_info(nature: &'static str) -> FnEndpoint {
FnEndpoint::new("host-info", move |_inv: &Invocation<'_>| {
let runtime = if cfg!(target_family = "wasm") {
"browser · wasm32".to_string()
} else {
format!(
"native · {}/{}",
std::env::consts::OS,
std::env::consts::ARCH
)
};
let body = format!(
"ikigai host\n nature {nature}\n runtime {runtime}\n \
space ikigai-fn (toUpper · reverseList · wrap · split · greet · echo · compose)\n"
);
Ok(Representation::new(
ReprType::new("text/plain").with_param("charset", "utf-8"),
body.into_bytes(),
))
})
.with_description(
Description::new("host-info")
.title("Host info")
.summary("Reports the kernel host's nature (embedded/remote + transport) and runtime.")
.verb(Verb::Source)
.verb(Verb::Meta)
.output("text/plain;charset=utf-8"),
)
}
pub fn scheduler() -> Scheduler {
static SCHEDULER: OnceLock<Scheduler> = OnceLock::new();
SCHEDULER
.get_or_init(|| match std::env::var("IKIGAI_SCHEDULER") {
Ok(spec) => Scheduler::from_config(&spec).unwrap_or_else(|e| {
eprintln!("ikigai: {e}; falling back to a single-threaded scheduler");
Scheduler::single()
}),
Err(_) => Scheduler::single(),
})
.clone()
}
pub fn demo_flag() -> Arc<AtomicBool> {
static DEMO: OnceLock<Arc<AtomicBool>> = OnceLock::new();
DEMO.get_or_init(|| Arc::new(AtomicBool::new(false)))
.clone()
}
struct Gated {
inner: EndpointSpace,
on: Arc<AtomicBool>,
}
impl Space for Gated {
fn resolve(&self, request: &Request, scope: &Scope) -> Resolution {
if self.on.load(Ordering::Relaxed) {
self.inner.resolve(request, scope)
} else {
Resolution::Miss
}
}
fn entries(&self) -> Option<Vec<SpaceEntry>> {
if self.on.load(Ordering::Relaxed) {
self.inner.entries()
} else {
Some(Vec::new())
}
}
}
fn host_demo() -> FnEndpoint {
FnEndpoint::new("host-demo", move |inv: &Invocation<'_>| {
let flag = demo_flag();
if let Ok(value) = inv.inline_str("content") {
let on = matches!(
value.trim().to_ascii_lowercase().as_str(),
"on" | "true" | "enable" | "enabled" | "yes" | "1"
);
flag.store(on, Ordering::SeqCst);
}
let state = if flag.load(Ordering::SeqCst) {
"on"
} else {
"off"
};
Ok(Representation::new(
ReprType::new("text/plain").with_param("charset", "utf-8"),
format!("demo {state}\n").into_bytes(),
))
})
.with_description(
Description::new("host-demo")
.title("Demo toggle")
.summary(
"The interactive runbook on/off — source reports it, `sink … on|off` flips it.",
)
.verb(Verb::Source)
.verb(Verb::Sink)
.verb(Verb::Meta)
.output("text/plain;charset=utf-8"),
)
}
fn ikigai_home() -> PathBuf {
let home = std::env::var_os("HOME").map_or_else(|| PathBuf::from("."), PathBuf::from);
let dir = home.join(".ikigai");
let _ = std::fs::create_dir_all(&dir);
dir
}
pub fn history_flag() -> Arc<AtomicBool> {
static HISTORY: OnceLock<Arc<AtomicBool>> = OnceLock::new();
HISTORY
.get_or_init(|| Arc::new(AtomicBool::new(history_marker().exists())))
.clone()
}
fn history_marker() -> PathBuf {
ikigai_home().join("history.on")
}
fn history_file(dir: &Path) -> PathBuf {
dir.join("history")
}
fn read_history(dir: &Path) -> Vec<String> {
std::fs::read_to_string(history_file(dir))
.map(|s| s.lines().map(str::to_string).collect())
.unwrap_or_default()
}
fn write_history(dir: &Path, line: &str) {
let line = line.trim();
if line.is_empty() {
return;
}
use std::io::Write;
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(history_file(dir))
{
let _ = writeln!(file, "{line}");
}
}
pub fn load_history() -> Vec<String> {
read_history(&ikigai_home())
}
pub fn append_history(line: &str) {
if !history_flag().load(Ordering::Relaxed) {
return;
}
write_history(&ikigai_home(), line);
}
pub fn set_history(on: bool) {
history_flag().store(on, Ordering::SeqCst);
let marker = history_marker();
if on {
let _ = std::fs::File::create(&marker); } else {
let _ = std::fs::remove_file(&marker);
}
}
fn host_history() -> FnEndpoint {
FnEndpoint::new("host-history", move |inv: &Invocation<'_>| {
if let Ok(value) = inv.inline_str("content") {
let on = matches!(
value.trim().to_ascii_lowercase().as_str(),
"on" | "true" | "enable" | "enabled" | "yes" | "1"
);
set_history(on);
}
let body = if history_flag().load(Ordering::SeqCst) {
format!("history on ({} entries)\n", load_history().len())
} else {
"history off\n".to_string()
};
Ok(Representation::new(
ReprType::new("text/plain").with_param("charset", "utf-8"),
body.into_bytes(),
))
})
.with_description(
Description::new("host-history")
.title("History toggle")
.summary(
"Persist command history across runs — source reports it, `sink … on|off` flips it.",
)
.verb(Verb::Source)
.verb(Verb::Sink)
.verb(Verb::Meta)
.output("text/plain;charset=utf-8"),
)
}
fn host_identity() -> FnEndpoint {
FnEndpoint::new("host-identity", move |inv: &Invocation<'_>| {
let who = inv
.capability
.scopes()
.and_then(|s| s.iter().find_map(|sc| sc.strip_prefix("urn:cap:fs:read:")))
.and_then(|path| path.rsplit(['/', '\\']).next())
.map(|id| id.to_string())
.unwrap_or_else(|| "root (full authority)".to_string());
Ok(Representation::new(
ReprType::new("text/plain").with_param("charset", "utf-8"),
format!("identity {who}\n").into_bytes(),
))
})
.with_description(
Description::new("host-identity")
.title("Identity")
.summary("Reports the identity the session resolves under (the session capability).")
.verb(Verb::Source)
.verb(Verb::Meta)
.output("text/plain;charset=utf-8"),
)
}
fn base_space(nature: &'static str) -> EndpointSpace {
ikigai_fn::space()
.bind(Exact::new("urn:data:page"), page())
.bind(Exact::new("urn:data:about"), about())
.bind(Exact::new("urn:host:info"), host_info(nature))
.bind(Exact::new("urn:host:demo"), host_demo())
.bind(Exact::new("urn:host:history"), host_history())
.bind(Exact::new("urn:host:identity"), host_identity())
}
pub fn file_root() -> PathBuf {
let root = std::env::var_os("IKIGAI_FILES")
.map(PathBuf::from)
.unwrap_or_else(|| {
let home = std::env::var_os("HOME").map_or_else(|| PathBuf::from("."), PathBuf::from);
home.join(".ikigai").join("workspace")
});
let _ = std::fs::create_dir_all(&root);
root
}
fn local_space(nature: &'static str) -> EndpointSpace {
base_space(nature)
.bind(
Exact::new("urn:personal:contacts"),
ikigai_personal::contacts(),
)
.bind(
Exact::new("urn:personal:calendar"),
ikigai_personal::calendar(),
)
.bind(
Exact::new("urn:personal:availability"),
ikigai_personal::availability(),
)
.bind(
UriTemplate::parse(ikigai_fs::FILE_TEMPLATE).expect("FILE_TEMPLATE is valid"),
ikigai_fs::FileEndpoint::new(file_root()).cacheable(),
)
}
fn served_space(nature: &'static str) -> EndpointSpace {
base_space(nature).bind(
UriTemplate::parse(ikigai_fs::FILE_TEMPLATE).expect("FILE_TEMPLATE is valid"),
ikigai_fs::FileEndpoint::new(file_root()).cacheable(),
)
}
struct UreqTransport;
#[async_trait::async_trait]
impl ikigai_http::HttpTransport for UreqTransport {
async fn send(
&self,
request: ikigai_http::HttpRequest,
) -> std::result::Result<ikigai_http::HttpResponse, String> {
use std::io::Read;
let mut req = ureq::request(request.method.as_str(), &request.url);
for (name, value) in &request.headers {
req = req.set(name, value);
}
let outcome = if request.body.is_empty() {
req.call()
} else {
req.send_bytes(&request.body)
};
let resp = match outcome {
Ok(resp) => resp,
Err(ureq::Error::Status(_, resp)) => resp,
Err(e) => return Err(e.to_string()),
};
let status = resp.status();
let headers = resp
.headers_names()
.into_iter()
.filter_map(|name| resp.header(&name).map(|v| (name.clone(), v.to_string())))
.collect();
let mut body = Vec::new();
if request.method != ikigai_http::Method::Head {
resp.into_reader()
.read_to_end(&mut body)
.map_err(|e| format!("reading response body: {e}"))?;
}
Ok(ikigai_http::HttpResponse {
status,
headers,
body,
})
}
}
fn http_space() -> EndpointSpace {
ikigai_http::space(Arc::new(UreqTransport))
}
fn root_space() -> Arc<dyn Space> {
Arc::new(Fallback::new(vec![
Arc::new(local_space("Embedded (Native)")) as Arc<dyn Space>,
Arc::new(http_space()) as Arc<dyn Space>,
Arc::new(Gated {
inner: ikigai_runbook::space(),
on: demo_flag(),
}) as Arc<dyn Space>,
]))
}
pub fn kernel() -> Kernel {
Kernel::with_meta_renderer(root_space(), Arc::new(CliRenderer))
.with_clock(Arc::new(SystemClock))
}
pub fn watched_kernel() -> Arc<Kernel> {
let sched = Arc::new(scheduler());
let kernel = Kernel::with_meta_renderer(root_space(), Arc::new(CliRenderer))
.with_clock(Arc::new(SystemClock))
.with_scheduler_reporter(sched.clone())
.into_scheduled(sched);
watch_root(Arc::clone(&kernel), file_root());
kernel
}
fn watch_root(kernel: Arc<Kernel>, root: PathBuf) {
let root = root.canonicalize().unwrap_or(root);
std::thread::spawn(move || {
let (tx, rx) = std::sync::mpsc::channel();
let mut watcher = match notify::recommended_watcher(move |res| {
let _ = tx.send(res);
}) {
Ok(watcher) => watcher,
Err(_) => return,
};
if watcher.watch(&root, RecursiveMode::Recursive).is_err() {
return;
}
for event in rx.iter().flatten() {
if event.kind.is_access() {
continue; }
for path in &event.paths {
if let Some(thread) = file_thread(&root, path) {
kernel.cut(thread);
}
}
}
});
}
fn file_thread(root: &Path, path: &Path) -> Option<String> {
let rel = path.strip_prefix(root).ok()?;
let joined = rel
.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/");
(!joined.is_empty()).then(|| format!("urn:file:{joined}"))
}
pub fn trusted_kernel_for(nature: &'static str) -> Kernel {
Kernel::with_meta_renderer(Arc::new(local_space(nature)), Arc::new(CliRenderer))
}
pub fn kernel_for(nature: &'static str) -> Kernel {
Kernel::with_meta_renderer(Arc::new(served_space(nature)), Arc::new(CliRenderer))
}
#[cfg(test)]
mod tests {
use super::*;
use futures::executor::block_on;
use ikigai_core::{ArgRef, Capability, Iri, Request};
#[test]
fn history_round_trips_lines() {
let dir = std::env::temp_dir().join(format!("ikigai-hist-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let _ = std::fs::remove_file(history_file(&dir));
assert!(read_history(&dir).is_empty(), "absent file → no history");
write_history(&dir, "source urn:fn:toUpper hi");
write_history(&dir, " "); write_history(&dir, "list");
assert_eq!(
read_history(&dir),
vec!["source urn:fn:toUpper hi".to_string(), "list".to_string()],
"appends in order, blanks dropped"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn wrap_routes_the_text_argument() {
let kernel = kernel();
let request = Request::new(Verb::Source, Iri::parse("urn:demo:wrap").unwrap())
.with_arg("text", ArgRef::Inline(b"hi".to_vec()));
let representation = block_on(kernel.issue(request, &Capability::root())).unwrap();
assert_eq!(representation.bytes, b"[hi]");
}
#[test]
fn split_makes_a_newline_list_for_map() {
let kernel = kernel();
let request = Request::new(Verb::Source, Iri::parse("urn:demo:split").unwrap())
.with_arg("in", ArgRef::Inline(b"a, b ,c".to_vec()));
let representation = block_on(kernel.issue(request, &Capability::root())).unwrap();
assert_eq!(representation.bytes, b"a\nb\nc");
}
#[test]
fn greet_combines_two_arguments() {
let kernel = kernel();
let request = Request::new(Verb::Source, Iri::parse("urn:demo:greet").unwrap())
.with_arg("greeting", ArgRef::Inline(b"Hello".to_vec()))
.with_arg("name", ArgRef::Inline(b"World".to_vec()));
let representation = block_on(kernel.issue(request, &Capability::root())).unwrap();
assert_eq!(representation.bytes, b"Hello, World");
}
#[test]
fn page_composes_through_the_linked_module() {
let kernel = kernel();
let request = Request::new(Verb::Source, Iri::parse("urn:fn:compose").unwrap())
.with_arg("src", ArgRef::Inline(b"urn:data:page".to_vec()));
let representation = block_on(kernel.issue(request, &Capability::root())).unwrap();
let text = String::from_utf8(representation.bytes).unwrap();
assert!(text.contains("RESOURCE ORIENTED COMPUTING"));
assert!(text.contains("[hello]"));
assert!(text.contains("Hi, World"));
assert!(text.contains("$a{urn:fn:toUpper?in=x}"));
}
#[test]
fn file_thread_maps_a_changed_path_to_its_urn() {
let root = Path::new("/ws");
assert_eq!(
file_thread(root, Path::new("/ws/notes.txt")).as_deref(),
Some("urn:file:notes.txt")
);
assert_eq!(
file_thread(root, Path::new("/ws/docs/a.txt")).as_deref(),
Some("urn:file:docs/a.txt")
);
assert_eq!(file_thread(root, root), None); assert_eq!(file_thread(root, Path::new("/elsewhere/x")), None);
}
#[test]
fn the_watcher_cuts_a_thread_on_an_out_of_band_change() {
use std::time::Duration;
let root = std::env::temp_dir().join(format!("ikigai-watch-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("notes.txt"), b"v1").unwrap();
let kernel = Arc::new(Kernel::new(Arc::new(ikigai_fs::cacheable_space(&root))));
watch_root(Arc::clone(&kernel), root.clone());
std::thread::sleep(Duration::from_millis(400)); let cap = Capability::root();
let source = || Request::new(Verb::Source, Iri::parse("urn:file:notes.txt").unwrap());
assert_eq!(block_on(kernel.issue(source(), &cap)).unwrap().bytes, b"v1");
assert!(
kernel.is_cached(&source(), &cap),
"cached after the first read"
);
std::fs::write(root.join("notes.txt"), b"v2").unwrap();
let mut cut = false;
for _ in 0..60 {
if !kernel.is_cached(&source(), &cap) {
cut = true;
break;
}
std::thread::sleep(Duration::from_millis(100));
}
assert!(
cut,
"watcher should cut the thread within ~6s of the change"
);
assert_eq!(block_on(kernel.issue(source(), &cap)).unwrap().bytes, b"v2");
std::fs::remove_dir_all(&root).ok();
}
}