Skip to main content

combs_mesh/engine/
registry.rs

1//! Content-addressed emoji store.
2//!
3//! Layout (mirrors the model cache convention — `$COMBS_HOME` respected,
4//! else `~/.cache/combs`):
5//!
6//! ```text
7//! $COMBS_HOME/mesh/            (default ~/.cache/combs/mesh)
8//! ├── <sha256-hex>.cmse        one file per registered emoji binary
9//! └── index.json               { name: { hash, bytes, block_count } }
10//! ```
11//!
12//! The hash is SHA-256 of the *plaintext* `.cmse` binary (same digest
13//! family as the zerotrust manifest hashing). `index.json` is a cache of
14//! convenience: a missing/corrupt index is rebuilt by scanning the
15//! directory.
16
17use std::collections::HashMap;
18use std::fs;
19use std::path::{Path, PathBuf};
20
21use serde::{Deserialize, Serialize};
22use sha2::{Digest, Sha256};
23
24use crate::engine::{Emoji, EmojiExporter};
25use crate::error::{MeshError, Result};
26
27/// One entry in the registry.
28#[derive(Debug, Clone, PartialEq)]
29pub struct RegistryEntry {
30    /// Registered name (from the emoji's text block).
31    pub name: String,
32    /// SHA-256 hex of the binary.
33    pub hash: String,
34    /// Path of the `.cmse` file.
35    pub path: PathBuf,
36    /// Size of the binary in bytes.
37    pub bytes: usize,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41struct IndexEntry {
42    hash: String,
43    bytes: usize,
44    block_count: usize,
45}
46
47/// The registry. Cheap to construct; all state lives on disk.
48#[derive(Debug, Clone)]
49pub struct Registry {
50    root: PathBuf,
51}
52
53impl Registry {
54    /// Opens the default registry (`$COMBS_HOME/mesh`, else
55    /// `~/.cache/combs/mesh`), creating the directory if needed.
56    pub fn open() -> Result<Registry> {
57        Registry::open_at(mesh_root()?)
58    }
59
60    /// Opens a registry rooted at an explicit directory (tests, custom
61    /// deployments).
62    pub fn open_at(root: PathBuf) -> Result<Registry> {
63        fs::create_dir_all(&root)?;
64        Ok(Registry { root })
65    }
66
67    /// The registry root directory.
68    #[must_use]
69    pub fn root(&self) -> &Path {
70        &self.root
71    }
72
73    /// Registers `emoji`: writes `<sha256>.cmse` and records the name in
74    /// the index. Returns the hash. Idempotent.
75    pub fn register(&self, emoji: &Emoji) -> Result<String> {
76        let binary = EmojiExporter::to_binary(emoji)?;
77        let hash = sha256_hex(&binary);
78        fs::write(self.root.join(format!("{hash}.cmse")), &binary)?;
79        let mut index = self.load_index();
80        let name = if emoji.name.is_empty() {
81            hash[..12].to_string()
82        } else {
83            emoji.name.clone()
84        };
85        index.insert(
86            name,
87            IndexEntry {
88                hash: hash.clone(),
89                bytes: binary.len(),
90                block_count: emoji.blocks.len(),
91            },
92        );
93        self.save_index(&index)?;
94        Ok(hash)
95    }
96
97    /// Resolves a name or a 64-char hex hash to an emoji.
98    pub fn resolve(&self, name_or_hash: &str) -> Result<Emoji> {
99        let index = self.load_index();
100        let hash = match index.get(name_or_hash) {
101            Some(entry) => entry.hash.clone(),
102            None if is_sha256_hex(name_or_hash) => name_or_hash.to_string(),
103            None => {
104                return Err(MeshError::Registry(format!(
105                    "no emoji named '{name_or_hash}'"
106                )));
107            }
108        };
109        let path = self.root.join(format!("{hash}.cmse"));
110        let bytes = fs::read(&path)
111            .map_err(|e| MeshError::Registry(format!("cannot read {}: {e}", path.display())))?;
112        Ok(EmojiExporter::from_binary(&bytes)?)
113    }
114
115    /// Lists all registered emojis. Rebuilds the index from the directory
116    /// when it is missing or corrupt.
117    pub fn list(&self) -> Result<Vec<RegistryEntry>> {
118        let index = self.load_index();
119        let mut entries: Vec<RegistryEntry> = index
120            .iter()
121            .map(|(name, e)| RegistryEntry {
122                name: name.clone(),
123                hash: e.hash.clone(),
124                path: self.root.join(format!("{}.cmse", e.hash)),
125                bytes: e.bytes,
126            })
127            .collect();
128        entries.sort_by(|a, b| a.name.cmp(&b.name));
129        Ok(entries)
130    }
131
132    /// Removes a name from the index; deletes the `.cmse` file when no
133    /// other name references the same hash. Returns whether the name
134    /// existed.
135    pub fn remove(&self, name: &str) -> Result<bool> {
136        let mut index = self.load_index();
137        let Some(entry) = index.remove(name) else {
138            return Ok(false);
139        };
140        if !index.values().any(|e| e.hash == entry.hash) {
141            let _ = fs::remove_file(self.root.join(format!("{}.cmse", entry.hash)));
142        }
143        self.save_index(&index)?;
144        Ok(true)
145    }
146
147    /// Loads the index; rebuilds from the directory on missing/corrupt.
148    fn load_index(&self) -> HashMap<String, IndexEntry> {
149        let path = self.root.join("index.json");
150        if let Ok(bytes) = fs::read(&path) {
151            if let Ok(index) = serde_json::from_slice::<HashMap<String, IndexEntry>>(&bytes) {
152                return index;
153            }
154        }
155        self.rebuild_index()
156    }
157
158    fn rebuild_index(&self) -> HashMap<String, IndexEntry> {
159        let mut index = HashMap::new();
160        if let Ok(dir) = fs::read_dir(&self.root) {
161            for file in dir.flatten() {
162                let path = file.path();
163                if path.extension().and_then(|e| e.to_str()) != Some("cmse") {
164                    continue;
165                }
166                let Ok(bytes) = fs::read(&path) else { continue };
167                let Ok(emoji) = EmojiExporter::from_binary(&bytes) else {
168                    continue;
169                };
170                let hash = sha256_hex(&bytes);
171                let name = if emoji.name.is_empty() {
172                    hash[..12].to_string()
173                } else {
174                    emoji.name
175                };
176                index.insert(
177                    name,
178                    IndexEntry {
179                        hash,
180                        bytes: bytes.len(),
181                        block_count: emoji.blocks.len(),
182                    },
183                );
184            }
185        }
186        let _ = self.save_index(&index);
187        index
188    }
189
190    fn save_index(&self, index: &HashMap<String, IndexEntry>) -> Result<()> {
191        let json = serde_json::to_vec_pretty(index)?;
192        fs::write(self.root.join("index.json"), json)?;
193        Ok(())
194    }
195}
196
197/// The default registry root: `$COMBS_HOME/mesh`, else
198/// `$HOME/.cache/combs/mesh` (mirrors `combs pull`'s cache resolution).
199pub fn mesh_root() -> Result<PathBuf> {
200    let root = std::env::var("COMBS_HOME")
201        .map(PathBuf::from)
202        .or_else(|_| {
203            std::env::var("HOME")
204                .or_else(|_| std::env::var("USERPROFILE"))
205                .map(|h| PathBuf::from(h).join(".cache/combs"))
206        })
207        .map_err(|_| MeshError::Registry("cannot locate a home directory (set COMBS_HOME)".into()))?;
208    Ok(root.join("mesh"))
209}
210
211/// SHA-256 hex digest (content address).
212#[must_use]
213pub fn sha256_hex(bytes: &[u8]) -> String {
214    let digest = Sha256::digest(bytes);
215    let mut hex = String::with_capacity(64);
216    for b in digest {
217        hex.push_str(&format!("{b:02x}"));
218    }
219    hex
220}
221
222fn is_sha256_hex(s: &str) -> bool {
223    s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
224}