use crate::file_root;
use crate::jsonl::{append, field, json_str, now_rfc3339, read_lines};
use ikigai_core::{
ActionSpec, ArgSpec, Description, Endpoint, Error, Invocation, ReprType, Representation,
Result, Verb,
};
pub const CAP_PEOPLE_WRITE: &str = "urn:cap:people:write";
pub const CAP_PEOPLE_READ: &str = "urn:cap:people:read";
pub fn ledger_path() -> std::path::PathBuf {
file_root().join("people.jsonl")
}
fn roster(lines: &[String]) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for line in lines {
let Some(email) = field(line, "email") else {
continue;
};
let key = email.trim().to_ascii_lowercase();
if key.is_empty() || !seen.insert(key) {
continue;
}
let name = field(line, "name").unwrap_or_default();
let name = if name.trim().is_empty() {
"(no name)".to_string()
} else {
name
};
let source = field(line, "source").unwrap_or_default();
let at = field(line, "at").unwrap_or_default();
out.push(format!("{name} <{}> {source} {at}", email.trim()));
}
out
}
fn history(lines: &[String], email: &str) -> Vec<String> {
let needle = email.trim().to_ascii_lowercase();
if needle.is_empty() {
return Vec::new();
}
lines
.iter()
.filter(|l| {
field(l, "email")
.map(|e| e.trim().to_ascii_lowercase() == needle)
.unwrap_or(false)
})
.map(|l| {
format!(
"{} {} {}",
field(l, "at").unwrap_or_default(),
field(l, "source").unwrap_or_default(),
field(l, "note").unwrap_or_default(),
)
})
.collect()
}
pub struct PeopleLedger {
pub path: std::path::PathBuf,
}
#[async_trait::async_trait]
impl Endpoint for PeopleLedger {
async fn invoke(&self, inv: &Invocation<'_>) -> Result<Representation> {
match inv.request.verb {
Verb::Sink => {
if !inv.capability.allows(CAP_PEOPLE_WRITE) {
return Err(Error::Denied(format!(
"recording a contact requires `{CAP_PEOPLE_WRITE}`"
)));
}
let arg = |name: &str| inv.inline_str(name).unwrap_or("").trim().to_string();
let email = arg("email");
if email.is_empty() {
return Err(Error::InvalidArgument {
name: "email".to_string(),
detail: "a contact needs an email address".to_string(),
});
}
let record = format!(
"{{\"name\":{},\"email\":{},\"source\":{},\"note\":{},\"at\":{}}}\n",
json_str(&arg("name")),
json_str(&email),
json_str(&arg("source")),
json_str(&arg("note")),
json_str(&now_rfc3339()),
);
append(&self.path, &record, "people")?;
Ok(Representation::new(
ReprType::new("text/plain"),
b"recorded".to_vec(),
))
}
Verb::Source => {
if !inv.capability.allows(CAP_PEOPLE_READ) {
return Err(Error::Denied(format!(
"reading the people ledger requires `{CAP_PEOPLE_READ}`"
)));
}
let lines = read_lines(&self.path);
let body = if let Ok(email) = inv.inline_str("email") {
history(&lines, email).join("\n")
} else {
roster(&lines).join("\n")
};
Ok(Representation::new(
ReprType::new("text/plain").with_param("charset", "utf-8"),
body.into_bytes(),
))
}
other => Err(Error::Endpoint(format!(
"people accepts Source (roster/lookup) or Sink (record), not {other:?}"
))),
}
}
fn name(&self) -> &str {
"people"
}
fn describe(&self) -> Description {
Description::new("people")
.title("People ledger")
.summary(
"A durable roster of everyone who has reached out — captured at ingestion, so a \
request that found no slot or was never decided still leaves a way to reach the \
person. Read it whole for the roster (one entry per address, newest first), or \
pass `email=<addr>` for that one person's contact history.",
)
.action(
ActionSpec::new(Verb::Source)
.summary("read the roster, or one person's history")
.requires(CAP_PEOPLE_READ)
.input(
ArgSpec::new("email")
.optional()
.summary("an address — its full contact history"),
),
)
.action(
ActionSpec::new(Verb::Sink)
.summary("record a contact")
.requires(CAP_PEOPLE_WRITE)
.input(ArgSpec::new("email").summary("the address — the ledger key"))
.input(ArgSpec::new("name").optional().summary("who they are"))
.input(
ArgSpec::new("source")
.optional()
.summary("where they came from — booking, contact, …"),
)
.input(
ArgSpec::new("note")
.optional()
.summary("freeform — the client link, an organisation, …"),
),
)
.output("text/plain; charset=utf-8")
}
}
#[cfg(test)]
mod tests {
use super::*;
use futures::executor::block_on;
use ikigai_core::{ArgRef, Capability, EndpointSpace, Exact, Iri, Kernel, Request};
use std::sync::Arc;
fn kernel(name: &str) -> Kernel {
let dir = std::env::temp_dir().join(format!("ikigai-people-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Kernel::new(Arc::new(EndpointSpace::new().bind(
Exact::new("urn:people"),
PeopleLedger {
path: dir.join("people.jsonl"),
},
)))
}
fn record(k: &Kernel, name: &str, email: &str, source: &str, note: &str) -> Result<()> {
block_on(
k.issue(
Request::new(Verb::Sink, Iri::parse("urn:people").unwrap())
.with_arg("name", ArgRef::Inline(name.into()))
.with_arg("email", ArgRef::Inline(email.into()))
.with_arg("source", ArgRef::Inline(source.into()))
.with_arg("note", ArgRef::Inline(note.into())),
&Capability::scoped([CAP_PEOPLE_WRITE]),
),
)
.map(|_| ())
}
fn read(k: &Kernel, email: Option<&str>) -> String {
let mut req = Request::new(Verb::Source, Iri::parse("urn:people").unwrap());
if let Some(e) = email {
req = req.with_arg("email", ArgRef::Inline(e.into()));
}
let rep = block_on(k.issue(req, &Capability::scoped([CAP_PEOPLE_READ]))).unwrap();
String::from_utf8(rep.bytes.clone()).unwrap()
}
#[test]
fn the_roster_has_one_entry_per_address_newest_first() {
let k = kernel("roster");
record(&k, "Ada", "ada@x.example", "booking", "via nigel").unwrap();
record(&k, "Bo", "bo@x.example", "contact", "Acme Ltd").unwrap();
let out = read(&k, None);
assert!(
out.lines().next().unwrap().contains("bo@x.example"),
"{out}"
);
assert!(out.contains("Ada <ada@x.example>"), "{out}");
assert_eq!(out.lines().count(), 2, "{out}");
}
#[test]
fn a_repeat_contact_folds_to_the_most_recent_entry() {
let k = kernel("fold");
record(&k, "Ada", "ada@x.example", "contact", "first").unwrap();
record(&k, "Ada Lovelace", "ADA@x.example", "booking", "second").unwrap();
let roster = read(&k, None);
assert_eq!(roster.lines().count(), 1, "{roster}");
assert!(roster.contains("Ada Lovelace"), "latest name: {roster}");
assert!(roster.contains("booking"), "latest source: {roster}");
let history = read(&k, Some("ada@x.example"));
assert_eq!(history.lines().count(), 2, "{history}");
}
#[test]
fn a_lookup_is_case_insensitive_on_the_address() {
let k = kernel("lookup");
record(&k, "Ada", "Ada@X.Example", "booking", "note").unwrap();
assert!(read(&k, Some("ada@x.example")).contains("booking"));
assert!(read(&k, Some("ADA@X.EXAMPLE")).contains("booking"));
assert_eq!(read(&k, Some("someone@else.example")).trim(), "");
}
#[test]
fn a_contact_without_an_address_is_rejected() {
let k = kernel("noemail");
let err = record(&k, "Nameless", "", "booking", "note").unwrap_err();
assert!(matches!(err, Error::InvalidArgument { .. }), "{err:?}");
assert_eq!(read(&k, None), "");
}
#[test]
fn an_empty_ledger_is_empty_not_an_error() {
let k = kernel("empty");
assert_eq!(read(&k, None), "");
assert_eq!(read(&k, Some("anyone@x.example")), "");
}
#[test]
fn reading_and_writing_are_separately_gated() {
let k = kernel("caps");
let denied = block_on(
k.issue(
Request::new(Verb::Sink, Iri::parse("urn:people").unwrap())
.with_arg("email", ArgRef::Inline(b"x@y.example".to_vec())),
&Capability::scoped([CAP_PEOPLE_READ]),
),
)
.unwrap_err();
assert!(matches!(denied, Error::Denied(_)), "{denied:?}");
}
#[test]
fn a_quote_in_a_name_cannot_break_the_record_line() {
let k = kernel("escape");
record(
&k,
r#"a" ,"source":"forged"#,
"x@y.example",
"booking",
"note",
)
.unwrap();
let out = read(&k, None);
assert_eq!(out.lines().count(), 1, "{out}");
assert!(
out.contains("x@y.example> booking "),
"the injected field did not become the source: {out}"
);
}
}