use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Arc;
use traits::ArtifactId;
use crate::promote::{coord_key, sanitize_repo};
pub type PropertyMap = BTreeMap<String, Vec<String>>;
pub struct PropertyStore {
root: PathBuf,
}
pub type SharedPropertyStore = Arc<PropertyStore>;
impl PropertyStore {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
fn doc_path(&self, repo: &str, id: &ArtifactId) -> PathBuf {
self.root
.join(sanitize_repo(repo))
.join(format!("{}.props.json", coord_key(id)))
}
pub fn get(&self, repo: &str, id: &ArtifactId) -> PropertyMap {
match std::fs::read(self.doc_path(repo, id)) {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
Err(_) => PropertyMap::new(),
}
}
pub fn set(
&self,
repo: &str,
id: &ArtifactId,
key: &str,
values: Vec<String>,
) -> std::io::Result<PropertyMap> {
let mut map = self.get(repo, id);
if values.is_empty() {
map.remove(key);
} else {
let mut deduped: Vec<String> = Vec::with_capacity(values.len());
for v in values {
if !deduped.contains(&v) {
deduped.push(v);
}
}
map.insert(key.to_string(), deduped);
}
self.write_map(repo, id, &map)?;
Ok(map)
}
fn write_map(&self, repo: &str, id: &ArtifactId, map: &PropertyMap) -> std::io::Result<()> {
let path = self.doc_path(repo, id);
if map.is_empty() {
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
} else {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let bytes = serde_json::to_vec(map).map_err(std::io::Error::other)?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, &bytes)?;
std::fs::rename(&tmp, &path)
}
}
pub fn matches(&self, repo: &str, id: &ArtifactId, key: &str, value: &str) -> bool {
map_matches(&self.get(repo, id), key, value)
}
}
pub fn map_matches(map: &PropertyMap, key: &str, value: &str) -> bool {
match map.get(key) {
Some(values) => value.is_empty() || values.iter().any(|v| v == value),
None => false,
}
}
pub fn parse_predicate(raw: &str) -> Option<(String, String)> {
let raw = raw.trim();
if raw.is_empty() {
return None;
}
match raw.split_once('=') {
Some((k, v)) if !k.trim().is_empty() => Some((k.trim().to_string(), v.to_string())),
None => Some((raw.to_string(), String::new())),
Some(_) => None, }
}
impl crate::search::PropertyLookup for PropertyStore {
fn properties_of(&self, repo: &str, id: &ArtifactId) -> PropertyMap {
self.get(repo, id)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn id(ns: Option<&str>, name: &str, ver: &str) -> ArtifactId {
ArtifactId { namespace: ns.map(str::to_string), name: name.into(), version: ver.into() }
}
#[test]
fn set_then_get_round_trip_multi_value() {
let tmp = tempfile::tempdir().unwrap();
let store = PropertyStore::new(tmp.path());
let coord = id(None, "serde", "1.0.0");
store.set("rust-prod", &coord, "env", vec!["prod".into(), "staging".into()]).unwrap();
store.set("rust-prod", &coord, "team", vec!["core".into()]).unwrap();
let got = store.get("rust-prod", &coord);
assert_eq!(got.get("env").unwrap(), &vec!["prod".to_string(), "staging".to_string()]);
assert_eq!(got.get("team").unwrap(), &vec!["core".to_string()]);
}
#[test]
fn set_dedups_values() {
let tmp = tempfile::tempdir().unwrap();
let store = PropertyStore::new(tmp.path());
let coord = id(None, "x", "1");
store.set("r", &coord, "k", vec!["a".into(), "a".into(), "b".into()]).unwrap();
assert_eq!(store.get("r", &coord).get("k").unwrap(), &vec!["a".to_string(), "b".to_string()]);
}
#[test]
fn set_empty_values_removes_key() {
let tmp = tempfile::tempdir().unwrap();
let store = PropertyStore::new(tmp.path());
let coord = id(None, "x", "1");
store.set("r", &coord, "k", vec!["v".into()]).unwrap();
assert!(store.get("r", &coord).contains_key("k"));
store.set("r", &coord, "k", vec![]).unwrap(); assert!(!store.get("r", &coord).contains_key("k"), "empty values removes the key");
}
#[test]
fn missing_coordinate_is_empty_and_never_matches() {
let tmp = tempfile::tempdir().unwrap();
let store = PropertyStore::new(tmp.path());
let coord = id(None, "never-set", "9.9.9");
assert!(store.get("r", &coord).is_empty());
assert!(!store.matches("r", &coord, "env", "prod"), "unset coord must not match");
}
#[test]
fn properties_do_not_leak_across_coordinate_or_repo() {
let tmp = tempfile::tempdir().unwrap();
let store = PropertyStore::new(tmp.path());
let a = id(Some("org.a"), "widget", "1.0");
let b = id(Some("org.b"), "widget", "1.0");
store.set("maven-prod", &a, "env", vec!["prod".into()]).unwrap();
assert!(!store.matches("maven-prod", &b, "env", "prod"), "coordinate B must not see A's props");
assert!(store.get("maven-prod", &b).is_empty());
assert!(!store.matches("maven-dev", &a, "env", "prod"), "repo is part of the key");
}
#[test]
fn matches_key_exists_vs_exact_value() {
let tmp = tempfile::tempdir().unwrap();
let store = PropertyStore::new(tmp.path());
let coord = id(None, "x", "1");
store.set("r", &coord, "env", vec!["prod".into()]).unwrap();
assert!(store.matches("r", &coord, "env", ""));
assert!(store.matches("r", &coord, "env", "prod"));
assert!(!store.matches("r", &coord, "env", "staging"));
assert!(!store.matches("r", &coord, "team", "prod"));
}
#[test]
fn overwriting_a_key_is_last_wins_and_keeps_other_keys() {
let tmp = tempfile::tempdir().unwrap();
let store = PropertyStore::new(tmp.path());
let coord = id(None, "x", "1");
store.set("r", &coord, "env", vec!["prod".into()]).unwrap();
store.set("r", &coord, "team", vec!["core".into()]).unwrap();
store.set("r", &coord, "env", vec!["staging".into()]).unwrap(); let got = store.get("r", &coord);
assert_eq!(got.get("env").unwrap(), &vec!["staging".to_string()], "env overwritten");
assert_eq!(got.get("team").unwrap(), &vec!["core".to_string()], "team preserved");
}
}