use std::path::{Path, PathBuf};
use std::sync::Arc;
use ikigai_core::{
ArgRef, Capability, EndpointSpace, Iri, Kernel, Request, Space, SystemClock, Verb,
};
pub const DIR: &str = "layers";
pub const STATE_KEY: &str = "layers.state";
pub const STATE_FLAG: &str = "--layers-state";
pub const SPACE: &str = "urn:ikigai:space:served:layers";
pub const VALIDATE: &str = ikigai_layers::endpoints::VALIDATE;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StateSource {
Flag,
Config,
Default,
}
impl StateSource {
pub fn as_str(self) -> &'static str {
match self {
StateSource::Flag => "flag",
StateSource::Config => "config",
StateSource::Default => "default",
}
}
}
pub struct Hosted {
pub host: Arc<ikigai_layers::Host>,
pub config: PathBuf,
pub state: PathBuf,
pub source: StateSource,
}
impl Hosted {
pub fn layer_lines(&self) -> Vec<String> {
self.host
.layers()
.iter()
.map(|(name, state)| format!("{name} ({})", state.def.model.as_str()))
.collect()
}
}
pub fn configured() -> Option<PathBuf> {
configured_in(&crate::config::config_home()?)
}
pub fn configured_in(home: &Path) -> Option<PathBuf> {
let dir = home.join(DIR);
dir.is_dir().then_some(dir)
}
pub fn state_dir(flag: Option<&Path>) -> Result<(PathBuf, StateSource), String> {
let instance = crate::instance_name();
let configured = crate::config::get(&format!("{instance}.{STATE_KEY}"))
.or_else(|| crate::config::get(STATE_KEY));
state_dir_from(
flag,
configured.as_deref(),
crate::config::config_home().as_deref(),
std::env::var_os("HOME").as_deref().map(Path::new),
instance,
)
}
pub fn state_dir_from(
flag: Option<&Path>,
configured: Option<&str>,
config_home: Option<&Path>,
home: Option<&Path>,
instance: &str,
) -> Result<(PathBuf, StateSource), String> {
if let Some(dir) = flag {
return Ok((dir.to_path_buf(), StateSource::Flag));
}
if let Some(value) = configured.map(str::trim).filter(|v| !v.is_empty()) {
let path = match (value.strip_prefix("~/"), home) {
(Some(rest), Some(home)) => home.join(rest),
_ => PathBuf::from(value),
};
let path = match config_home {
Some(base) if path.is_relative() => base.join(path),
_ => path,
};
return Ok((path, StateSource::Config));
}
let home = home.filter(|h| !h.as_os_str().is_empty()).ok_or_else(|| {
format!(
"layers are configured but no state directory is: pass {STATE_FLAG} <dir>, set \
`{STATE_KEY}` in the config home, or set HOME so the default \
(~/.ikigai/layers/{instance}) has somewhere to be"
)
})?;
Ok((
ikigai_core::config::data_home_in(home)
.join("layers")
.join(instance),
StateSource::Default,
))
}
pub fn open(flag: Option<&Path>) -> Result<Option<Hosted>, String> {
let Some(home) = crate::config::config_home() else {
return match flag {
Some(_) => Err(format!("{STATE_FLAG} needs a config home holding `{DIR}/`")),
None => Ok(None),
};
};
if configured_in(&home).is_none() {
return match flag {
Some(_) => Err(format!(
"{STATE_FLAG} names where layers keep their state, but {} does not exist, so \
no layer is configured",
home.join(DIR).display()
)),
None => Ok(None),
};
}
let (state, source) = state_dir(flag)?;
let host = ikigai_layers::Host::open(&home, &state).map_err(|e| {
format!(
"layers are configured in {} but the host cannot open: {e}",
home.join(DIR).display()
)
})?;
Ok(Some(Hosted {
host,
config: home,
state,
source,
}))
}
pub fn spaces(host: &Arc<ikigai_layers::Host>) -> Vec<Arc<dyn Space>> {
let layers: EndpointSpace = ikigai_layers::space(host)
.named(Iri::parse(SPACE).expect("the layer space's name is an IRI"));
vec![
Arc::new(layers) as Arc<dyn Space>,
Arc::new(ikigai_shacl::space()) as Arc<dyn Space>,
]
}
pub fn clock() -> Arc<dyn ikigai_core::Clock> {
Arc::new(SystemClock)
}
pub fn check_validator(kernel: &Kernel) -> Result<(), String> {
const SHAPES: &str = "@prefix sh: <http://www.w3.org/ns/shacl#> .\n\
<urn:ikigai:layers:probe:shape> a sh:NodeShape ;\n \
sh:targetClass <urn:ikigai:layers:probe:Thing> .\n";
const DATA: &str = "<urn:ikigai:layers:probe:thing> a <urn:ikigai:layers:probe:Thing> .\n";
let refuse = |why: String| {
format!(
"layers are configured, but admission cannot run on this kernel: {why}. Every \
write to a layer composes {VALIDATE}, and a layer without it admits nothing, so \
this door does not start"
)
};
let request = Request::new(Verb::Source, Iri::parse(VALIDATE).expect("a constant IRI"))
.with_arg("data", ArgRef::Inline(DATA.as_bytes().to_vec()))
.with_arg("shapes", ArgRef::Inline(SHAPES.as_bytes().to_vec()))
.with_arg("as", ArgRef::Inline(b"application/json".to_vec()));
let (report, _) = ikigai_resolve::Resolver::issue_as(
kernel,
request,
&Capability::scoped(Vec::<String>::new()),
)
.map_err(|e| refuse(format!("{VALIDATE} failed ({e})")))?;
let report: serde_json::Value = serde_json::from_slice(&report.bytes).map_err(|e| {
refuse(format!(
"{VALIDATE} answered something that is not a report ({e})"
))
})?;
match report.get("conforms").and_then(serde_json::Value::as_bool) {
Some(true) => Ok(()),
other => Err(refuse(format!(
"{VALIDATE} judged a conforming probe {}",
match other {
Some(false) => "NOT conforming".to_string(),
_ => "without a `conforms`".to_string(),
}
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use ikigai_core::Fallback;
#[test]
fn the_flag_wins_then_the_config_home_then_the_data_home() {
let flag = Path::new("/srv/flagged");
let home = Path::new("/home/b");
let config = Path::new("/home/b/.config/ikigai");
assert_eq!(
state_dir_from(
Some(flag),
Some("ignored"),
Some(config),
Some(home),
"layers"
),
Ok((flag.to_path_buf(), StateSource::Flag))
);
assert_eq!(
state_dir_from(None, Some("state"), Some(config), Some(home), "layers"),
Ok((config.join("state"), StateSource::Config))
);
assert_eq!(
state_dir_from(
None,
Some("~/layer-state"),
Some(config),
Some(home),
"layers"
),
Ok((home.join("layer-state"), StateSource::Config))
);
assert_eq!(
state_dir_from(
None,
Some("/var/layers"),
Some(config),
Some(home),
"layers"
),
Ok((PathBuf::from("/var/layers"), StateSource::Config))
);
assert_eq!(
state_dir_from(None, None, Some(config), Some(home), "layers"),
Ok((home.join(".ikigai/layers/layers"), StateSource::Default))
);
assert_eq!(
state_dir_from(None, Some(" "), Some(config), Some(home), "edge"),
Ok((home.join(".ikigai/layers/edge"), StateSource::Default)),
"a blank setting is no setting"
);
let err = state_dir_from(None, None, Some(config), None, "layers").unwrap_err();
assert!(err.contains(STATE_FLAG) && err.contains(STATE_KEY), "{err}");
}
#[test]
fn only_a_directory_switches_the_host_on() {
let dir = std::env::temp_dir().join(format!("iki-layers-switch-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
assert_eq!(configured_in(&dir), None);
std::fs::write(dir.join(DIR), "not a directory").unwrap();
assert_eq!(
configured_in(&dir),
None,
"a FILE named layers is not a configuration"
);
std::fs::remove_file(dir.join(DIR)).unwrap();
std::fs::create_dir(dir.join(DIR)).unwrap();
assert_eq!(configured_in(&dir), Some(dir.join(DIR)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_kernel_without_the_validator_fails_the_check_and_one_with_it_passes() {
let bare = Kernel::new(Arc::new(EndpointSpace::new()));
let err = check_validator(&bare).unwrap_err();
assert!(err.contains(VALIDATE), "names the validator: {err}");
assert!(err.contains("does not start"), "says what follows: {err}");
let with: Kernel = Kernel::new(Arc::new(Fallback::new(vec![
Arc::new(ikigai_shacl::space()) as Arc<dyn Space>,
])));
check_validator(&with).expect("the real validator admits the probe");
}
}