use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use ikigai_core::{ArgRef, EndpointSpace, Iri, Kernel, Request, Space, Verb};
pub const STATE_FLAG: &str = "--bayes-state";
pub const MODEL_FLAG: &str = "--bayes-model";
pub const STATE_KEY: &str = "bayes.state";
pub const MODELS_KEY: &str = "bayes.models";
pub const SPACE: &str = "urn:ikigai:space:bayes";
static FLAGS: Mutex<Option<Flags>> = Mutex::new(None);
static OPENED: OnceLock<Option<Opened>> = OnceLock::new();
#[derive(Clone, Debug, Default)]
struct Flags {
state: Option<PathBuf>,
models: Vec<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Source {
Flag,
Config,
}
impl Source {
pub fn as_str(self) -> &'static str {
match self {
Source::Flag => "flag",
Source::Config => "config",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Setting {
pub state: PathBuf,
pub state_source: Source,
pub models: Vec<String>,
pub models_source: Source,
}
struct Opened {
space: Arc<dyn Space>,
line: String,
}
pub fn set_flags(state: Option<PathBuf>, models: Vec<String>) {
*FLAGS.lock().expect("bayes flags lock") = Some(Flags { state, models });
}
pub fn flags_given() -> bool {
FLAGS
.lock()
.expect("bayes flags lock")
.as_ref()
.is_some_and(|f| f.state.is_some() || !f.models.is_empty())
}
pub fn arm() -> Result<Option<String>, String> {
if let Some(opened) = OPENED.get() {
return Ok(opened.as_ref().map(|o| o.line.clone()));
}
let flags = FLAGS
.lock()
.expect("bayes flags lock")
.clone()
.unwrap_or_default();
let instance = crate::instance_name();
let config_state = crate::config::get(&format!("{instance}.{STATE_KEY}"))
.or_else(|| crate::config::get(STATE_KEY));
let mut config_models = crate::config::all(&format!("{instance}.{MODELS_KEY}"));
if config_models.is_empty() {
config_models = crate::config::all(MODELS_KEY);
}
let setting = setting_from(
flags.state.as_deref(),
&flags.models,
config_state.as_deref(),
&config_models,
crate::config::config_home().as_deref(),
std::env::var_os("HOME").as_deref().map(Path::new),
)?;
let opened = match setting {
None => None,
Some(setting) => Some(open(&setting)?),
};
let line = opened.as_ref().map(|o| o.line.clone());
let _ = OPENED.set(opened);
Ok(line)
}
pub(crate) fn space() -> Option<Arc<dyn Space>> {
OPENED
.get()
.and_then(|opened| opened.as_ref().map(|o| Arc::clone(&o.space)))
}
pub fn setting_from(
flag_state: Option<&Path>,
flag_models: &[String],
config_state: Option<&str>,
config_models: &[String],
config_home: Option<&Path>,
home: Option<&Path>,
) -> Result<Option<Setting>, String> {
let state = match (
flag_state,
config_state.map(str::trim).filter(|v| !v.is_empty()),
) {
(Some(dir), _) => Some((dir.to_path_buf(), Source::Flag)),
(None, Some(value)) => {
let path = ikigai_resolve::expand_home(value, home)
.map_err(|e| format!("`{STATE_KEY}` in the config home: {e}"))?;
let path = match config_home {
Some(base) if path.is_relative() => base.join(path),
_ => path,
};
Some((path, Source::Config))
}
(None, None) => None,
};
let models = if !flag_models.is_empty() {
Some((flag_models.to_vec(), Source::Flag))
} else {
let named: Vec<String> = config_models
.iter()
.flat_map(|line| line.split([',', ' ', '\t']))
.map(str::trim)
.filter(|name| !name.is_empty())
.map(String::from)
.collect();
(!named.is_empty()).then_some((named, Source::Config))
};
match (state, models) {
(None, None) => Ok(None),
(Some((state, state_source)), Some((models, models_source))) => Ok(Some(Setting {
state,
state_source,
models,
models_source,
})),
(Some((state, _)), None) => Err(format!(
"bayes: a state directory is named ({}) but no model is: pass {MODEL_FLAG} <name> \
(repeatable) or set `{MODELS_KEY}` in the config home. With no model named, \
nothing is opened",
state.display()
)),
(None, Some((models, _))) => Err(format!(
"bayes: model{} {} named but no state directory is: pass {STATE_FLAG} <dir> or set \
`{STATE_KEY}` in the config home",
if models.len() == 1 { " is" } else { "s are" },
models.join(", ")
)),
}
}
fn open(setting: &Setting) -> Result<Opened, String> {
prepare_dir(&setting.state)?;
let bayes = ikigai_bayes::Bayes::open(
&setting.state,
setting
.models
.iter()
.map(|name| ikigai_bayes::ModelConfig::new(name.as_str())),
)
.map_err(|e| format!("bayes: {e}"))?;
let space: EndpointSpace = ikigai_bayes::space(&bayes)
.named(Iri::parse(SPACE).expect("the bayes space's name is an IRI"));
let space: Arc<dyn Space> = Arc::new(space);
let counts = setting
.models
.iter()
.map(|name| model_counts(&space, name).map(|(spam, ham)| (name.as_str(), spam, ham)))
.collect::<Result<Vec<_>, String>>()?;
Ok(Opened {
line: startup_line(&counts, &setting.state, setting.state_source),
space,
})
}
pub fn startup_line(counts: &[(&str, u64, u64)], state: &Path, source: Source) -> String {
let models: Vec<String> = counts
.iter()
.map(|(name, spam, ham)| {
format!("{name} ({} messages: {spam} spam, {ham} ham)", spam + ham)
})
.collect();
format!(
"ikigai: bayes: {} — state in {} ({})",
models.join(", "),
state.display(),
source.as_str()
)
}
fn model_counts(space: &Arc<dyn Space>, model: &str) -> Result<(u64, u64), String> {
let kernel = Kernel::new(Arc::clone(space));
let iri = Iri::parse(ikigai_bayes::model_iri(model, "stats"))
.map_err(|e| format!("bayes: model `{model}`: {e}"))?;
let request = Request::new(Verb::Source, iri)
.with_arg("as", ArgRef::Inline(b"application/json".to_vec()));
let (rep, _) = ikigai_resolve::Resolver::issue(&kernel, request)
.map_err(|e| format!("bayes: model `{model}`: its stats did not resolve: {e}"))?;
let stats: serde_json::Value = serde_json::from_slice(&rep.bytes)
.map_err(|e| format!("bayes: model `{model}`: its stats are not JSON: {e}"))?;
let count = |label: &str| {
stats["messages"][label]
.as_u64()
.ok_or_else(|| format!("bayes: model `{model}`: its stats carry no {label} count"))
};
Ok((count("spam")?, count("ham")?))
}
fn prepare_dir(dir: &Path) -> Result<(), String> {
if !dir.exists() {
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o700);
}
builder
.create(dir)
.map_err(|e| format!("bayes: create the state directory {}: {e}", dir.display()))?;
return Ok(());
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(dir) {
let mode = meta.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
eprintln!(
"ikigai: bayes: the state directory {} is mode {mode:o}; it holds the token \
set of every trained message, so 700 is what it wants (chmod 700 {})",
dir.display(),
dir.display()
);
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn s(names: &[&str]) -> Vec<String> {
names.iter().map(|n| n.to_string()).collect()
}
#[test]
fn nothing_named_is_nothing_bound() {
assert_eq!(setting_from(None, &[], None, &[], None, None), Ok(None));
assert_eq!(
setting_from(None, &[], Some(" "), &s(&["", " "]), None, None),
Ok(None),
"a blank setting is no setting"
);
}
#[test]
fn the_flags_win_then_the_config_home() {
let config = Path::new("/home/b/.config/ikigai");
let home = Path::new("/home/b");
let got = setting_from(
Some(Path::new("/srv/bayes")),
&s(&["contact"]),
Some("ignored"),
&s(&["other"]),
Some(config),
Some(home),
)
.unwrap()
.unwrap();
assert_eq!(got.state, PathBuf::from("/srv/bayes"));
assert_eq!(got.state_source, Source::Flag);
assert_eq!(got.models, s(&["contact"]));
assert_eq!(got.models_source, Source::Flag);
let got = setting_from(
None,
&[],
Some("bayes"),
&s(&["contact, other", "third"]),
Some(config),
Some(home),
)
.unwrap()
.unwrap();
assert_eq!(got.state, config.join("bayes"));
assert_eq!(got.state_source, Source::Config);
assert_eq!(got.models, s(&["contact", "other", "third"]));
assert_eq!(got.models_source, Source::Config);
let got = setting_from(
None,
&[],
Some("~/.ikigai/bayes"),
&s(&["contact"]),
Some(config),
Some(home),
)
.unwrap()
.unwrap();
assert_eq!(got.state, home.join(".ikigai/bayes"));
let got = setting_from(
Some(Path::new("/srv/bayes")),
&[],
None,
&s(&["contact"]),
Some(config),
Some(home),
)
.unwrap()
.unwrap();
assert_eq!(
(got.state_source, got.models_source),
(Source::Flag, Source::Config)
);
}
#[test]
fn half_a_setting_is_refused_by_name() {
let err =
setting_from(Some(Path::new("/srv/bayes")), &[], None, &[], None, None).unwrap_err();
assert!(
err.contains(MODEL_FLAG) && err.contains(MODELS_KEY),
"{err}"
);
let err = setting_from(None, &s(&["contact"]), None, &[], None, None).unwrap_err();
assert!(
err.contains(STATE_FLAG) && err.contains(STATE_KEY) && err.contains("contact"),
"{err}"
);
let err =
setting_from(None, &[], Some("~bob/x"), &s(&["contact"]), None, None).unwrap_err();
assert!(err.contains(STATE_KEY), "{err}");
}
#[test]
fn the_startup_line_names_every_model_and_the_state() {
assert_eq!(
startup_line(
&[("contact", 2, 1), ("other", 0, 0)],
Path::new("/srv/bayes"),
Source::Flag
),
"ikigai: bayes: contact (3 messages: 2 spam, 1 ham), other (0 messages: 0 spam, 0 \
ham) — state in /srv/bayes (flag)"
);
}
}