Skip to main content

kevy_index/
view_sidecar.rs

1//! View catalog + sidecar text round-trip — the persistence face of
2//! [`crate::view`] (split out to keep `view.rs` under the 500-LOC
3//! project ceiling; behaviour unchanged).
4
5use crate::value::IndexValue;
6use std::fmt::Write as _;
7use crate::view::{Leaf, Tree, ViewMode, ViewSpec};
8
9/// The view registry (mirrors [`crate::Catalog`]): named specs +
10/// sidecar text round-trip. Cap 64.
11#[derive(Debug, Clone, Default)]
12pub struct ViewCatalog {
13    specs: Vec<ViewSpec>,
14}
15
16/// Hard cap on declared views.
17pub const MAX_VIEWS: usize = 64;
18
19impl ViewCatalog {
20    /// Empty catalog.
21    pub fn new() -> Self {
22        Self::default()
23    }
24
25    /// Register; errors on duplicate/cap/structure.
26    pub fn create(&mut self, spec: ViewSpec) -> Result<(), &'static str> {
27        spec.validate()?;
28        if self.specs.len() >= MAX_VIEWS {
29            return Err("ERR view limit reached (64)");
30        }
31        if self.specs.iter().any(|s| s.name == spec.name) {
32            return Err("ERR view already exists");
33        }
34        self.specs.push(spec);
35        Ok(())
36    }
37
38    /// Drop by name.
39    pub fn drop_view(&mut self, name: &[u8]) -> bool {
40        let n = self.specs.len();
41        self.specs.retain(|s| s.name != name);
42        self.specs.len() != n
43    }
44
45    /// Lookup.
46    pub fn get(&self, name: &[u8]) -> Option<&ViewSpec> {
47        self.specs.iter().find(|s| s.name == name)
48    }
49
50    /// Declaration order.
51    pub fn iter(&self) -> impl Iterator<Item = &ViewSpec> {
52        self.specs.iter()
53    }
54
55    /// Count.
56    pub fn len(&self) -> usize {
57        self.specs.len()
58    }
59
60    /// Empty?
61    pub fn is_empty(&self) -> bool {
62        self.specs.is_empty()
63    }
64
65    /// Sidecar text (one line per view).
66    pub fn to_sidecar(&self) -> String {
67        let mut out = String::from("kevy-view-catalog v1\n");
68        for s in &self.specs {
69            out.push_str(&s.to_line());
70            out.push('\n');
71        }
72        out
73    }
74
75    /// Parse the sidecar text.
76    pub fn from_sidecar(text: &str) -> Option<ViewCatalog> {
77        let mut lines = text.lines();
78        if lines.next()? != "kevy-view-catalog v1" {
79            return None;
80        }
81        let mut c = ViewCatalog::new();
82        for line in lines {
83            if line.is_empty() {
84                continue;
85            }
86            c.create(ViewSpec::from_line(line)?).ok()?;
87        }
88        Some(c)
89    }
90}
91
92fn esc(b: &[u8]) -> String {
93    let mut out = String::with_capacity(b.len());
94    for &c in b {
95        if c == b' ' || c == b'\t' || c == b'\n' || c == b'%' || c == b'(' || c == b')' || !(33..127).contains(&c) {
96            let _ = write!(out, "%{c:02X}");
97        } else {
98            out.push(c as char);
99        }
100    }
101    if out.is_empty() { "%".into() } else { out }
102}
103
104fn unesc(s: &str) -> Option<Vec<u8>> {
105    if s == "%" {
106        return Some(Vec::new());
107    }
108    let mut out = Vec::with_capacity(s.len());
109    let b = s.as_bytes();
110    let mut i = 0;
111    while i < b.len() {
112        if b[i] == b'%' {
113            out.push(u8::from_str_radix(s.get(i + 1..i + 3)?, 16).ok()?);
114            i += 3;
115        } else {
116            out.push(b[i]);
117            i += 1;
118        }
119    }
120    Some(out)
121}
122
123fn val_ser(v: &IndexValue) -> String {
124    match v {
125        IndexValue::I64(i) => format!("i{i}"),
126        IndexValue::F64(f) => format!("f{}", f.to_bits()),
127        IndexValue::Str(s) => format!("s{}", esc(s)),
128    }
129}
130
131fn val_de(s: &str) -> Option<IndexValue> {
132    let (tag, rest) = s.split_at(1);
133    match tag {
134        "i" => rest.parse().ok().map(IndexValue::I64),
135        "f" => rest.parse::<u64>().ok().map(|b| IndexValue::F64(f64::from_bits(b))),
136        "s" => unesc(rest).map(IndexValue::Str),
137        _ => None,
138    }
139}
140
141fn tree_ser(t: &Tree, out: &mut String) {
142    match t {
143        Tree::Leaf(l) => {
144            let _ = write!(out, "(L {} {} {})", esc(&l.index), val_ser(&l.min), val_ser(&l.max));
145        }
146        Tree::And(a, b) | Tree::Or(a, b) | Tree::Diff(a, b) => {
147            let op = match t {
148                Tree::And(..) => "A",
149                Tree::Or(..) => "O",
150                _ => "D",
151            };
152            let _ = write!(out, "({op} ");
153            tree_ser(a, out);
154            out.push(' ');
155            tree_ser(b, out);
156            out.push(')');
157        }
158    }
159}
160
161fn tree_de(toks: &[&str], pos: &mut usize) -> Option<Tree> {
162    let t = toks.get(*pos)?;
163    *pos += 1;
164    match *t {
165        "(L" => {
166            let idx = unesc(toks.get(*pos)?)?;
167            let min = val_de(toks.get(*pos + 1)?)?;
168            let max = val_de(toks.get(*pos + 2)?.trim_end_matches(')'))?;
169            *pos += 3;
170            Some(Tree::Leaf(Leaf { index: idx, min, max }))
171        }
172        "(A" | "(O" | "(D" => {
173            let a = tree_de(toks, pos)?;
174            let b = tree_de(toks, pos)?;
175            let tree = match *t {
176                "(A" => Tree::And(Box::new(a), Box::new(b)),
177                "(O" => Tree::Or(Box::new(a), Box::new(b)),
178                _ => Tree::Diff(Box::new(a), Box::new(b)),
179            };
180            Some(tree)
181        }
182        _ => None,
183    }
184}
185
186impl ViewSpec {
187    /// One sidecar line: `name order_by desc mode topk via tree…`.
188    pub fn to_line(&self) -> String {
189        let (mode, k) = match self.mode {
190            ViewMode::Virtual => ("v", 0),
191            ViewMode::Materialized { top_k } => ("m", top_k),
192        };
193        let via = self.via.as_deref().map_or_else(|| "-".into(), esc);
194        let mut tree = String::new();
195        tree_ser(&self.tree, &mut tree);
196        format!(
197            "{} {} {} {} {} {} {}",
198            esc(&self.name),
199            esc(&self.order_by),
200            u8::from(self.desc),
201            mode,
202            k,
203            via,
204            tree
205        )
206    }
207
208    /// Parse [`Self::to_line`].
209    pub fn from_line(line: &str) -> Option<ViewSpec> {
210        let toks: Vec<&str> = line.split(' ').collect();
211        if toks.len() < 7 {
212            return None;
213        }
214        let mode = match toks[3] {
215            "v" => ViewMode::Virtual,
216            "m" => ViewMode::Materialized { top_k: toks[4].parse().ok()? },
217            _ => return None,
218        };
219        let via = if toks[5] == "-" { None } else { Some(unesc(toks[5])?) };
220        let mut pos = 6;
221        // Re-tokenize the tree tail with ')' handling: split keeps
222        // parens attached; tree_de trims them.
223        let tree = tree_de_root(&toks, &mut pos)?;
224        Some(ViewSpec {
225            name: unesc(toks[0])?,
226            order_by: unesc(toks[1])?,
227            desc: toks[2] == "1",
228            mode,
229            via,
230            tree,
231        })
232    }
233}
234
235fn tree_de_root(toks: &[&str], pos: &mut usize) -> Option<Tree> {
236    // Fixed arity makes parens redundant on the way back in: each op
237    // token consumes exactly two subtrees, each leaf exactly three
238    // value tokens (the last with its trailing parens trimmed). The
239    // serializer is the only producer; malformed input answers None.
240    tree_de(toks, pos)
241}