use std::path::Path;
use std::sync::{Mutex, MutexGuard, PoisonError};
use indexmap::IndexMap;
use oino_ai::JsonValue;
use serde::{Deserialize, Serialize};
pub const GIT_TOPIC: &str = "oino:git";
pub const SETTINGS_TOPIC_PREFIX: &str = "oino:settings:";
#[must_use]
pub fn settings_topic(namespace: &str) -> String {
format!("{SETTINGS_TOPIC_PREFIX}{namespace}")
}
pub const CORE_PUBLISHER: &str = "oino.core";
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TopicValue {
pub topic: String,
pub data: JsonValue,
pub sticky: bool,
pub publisher: String,
}
#[must_use]
pub fn topic_matches(pattern: &str, topic: &str) -> bool {
pattern == "*"
|| pattern == topic
|| pattern
.strip_suffix('*')
.is_some_and(|prefix| topic.starts_with(prefix))
}
#[must_use]
pub fn any_matches(patterns: &[String], topic: &str) -> bool {
patterns.iter().any(|p| topic_matches(p, topic))
}
#[derive(Debug, Default)]
pub struct TopicStore {
sticky: Mutex<IndexMap<String, TopicValue>>,
}
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(PoisonError::into_inner)
}
impl TopicStore {
pub fn publish(
&self,
publisher: &str,
topic: &str,
data: JsonValue,
sticky: bool,
) -> Option<TopicValue> {
let value = TopicValue {
topic: topic.to_owned(),
data,
sticky,
publisher: publisher.to_owned(),
};
if sticky {
let mut map = lock(&self.sticky);
if map.get(topic).is_some_and(|old| old.data == value.data) {
return None;
}
map.insert(topic.to_owned(), value.clone());
}
Some(value)
}
#[must_use]
pub fn get(&self, topic: &str) -> Option<TopicValue> {
lock(&self.sticky).get(topic).cloned()
}
#[must_use]
pub fn snapshot(&self, patterns: &[String]) -> Vec<TopicValue> {
lock(&self.sticky)
.values()
.filter(|v| any_matches(patterns, &v.topic))
.cloned()
.collect()
}
}
#[must_use]
pub fn git_info(cwd: &Path) -> JsonValue {
let output = std::process::Command::new("git")
.args([
"status",
"--porcelain=v2",
"--branch",
"--untracked-files=normal",
])
.current_dir(cwd)
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output();
match output {
Ok(output) if output.status.success() => {
parse_git_status(&String::from_utf8_lossy(&output.stdout))
}
_ => JsonValue::Null,
}
}
fn parse_git_status(text: &str) -> JsonValue {
let mut branch = None;
let mut head = None;
let (mut ahead, mut behind) = (0_i64, 0_i64);
let mut changed = 0_u64;
for line in text.lines() {
if let Some(rest) = line.strip_prefix("# branch.head ") {
branch = (rest != "(detached)").then(|| rest.to_owned());
} else if let Some(rest) = line.strip_prefix("# branch.oid ") {
head = (rest != "(initial)").then(|| rest.chars().take(7).collect::<String>());
} else if let Some(rest) = line.strip_prefix("# branch.ab ") {
for part in rest.split_whitespace() {
if let Some(n) = part.strip_prefix('+') {
ahead = n.parse().unwrap_or(0);
} else if let Some(n) = part.strip_prefix('-') {
behind = n.parse().unwrap_or(0);
}
}
} else if !line.starts_with('#') && !line.is_empty() {
changed += 1;
}
}
serde_json::json!({
"branch": branch,
"head": head,
"dirty": changed > 0,
"changed": changed,
"ahead": ahead,
"behind": behind,
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn patterns() {
assert!(topic_matches("acme:*", "acme:bg:tasks"));
assert!(topic_matches("*", "x"));
assert!(topic_matches("a", "a"));
assert!(!topic_matches("a", "ab"));
assert!(any_matches(&["x".into(), "oino:*".into()], GIT_TOPIC));
}
#[test]
fn sticky_values_dedupe_and_snapshot() {
let store = TopicStore::default();
assert!(store.publish("e", "a:x", json!(1), true).is_some());
assert!(store.publish("e", "a:x", json!(1), true).is_none());
assert!(store.publish("e", "a:x", json!(2), true).is_some());
assert!(store.publish("e", "b:once", json!(1), false).is_some());
assert!(store.publish("e", "b:once", json!(1), false).is_some());
assert_eq!(store.snapshot(&["*".into()]).len(), 1);
assert_eq!(store.get("a:x").unwrap().data, json!(2));
assert!(store.get("b:once").is_none());
assert_eq!(store.snapshot(&["b:*".into()]), Vec::new());
}
#[test]
fn git_status_parse() {
let v = parse_git_status(
"# branch.oid 0123456789abcdef\n# branch.head main\n# branch.ab +2 -1\n1 .M N... 100644 100644 100644 a b f.rs\n? new.txt\n",
);
assert_eq!(
v,
json!({"branch": "main", "head": "0123456", "dirty": true, "changed": 2, "ahead": 2, "behind": 1})
);
let clean = parse_git_status("# branch.oid (initial)\n# branch.head (detached)\n");
assert_eq!(clean["dirty"], json!(false));
assert_eq!(clean["branch"], JsonValue::Null);
}
}