git_meta_lib/session_handle.rs
1use crate::error::{Error, Result};
2use crate::session::Session;
3use crate::types::{MetaValue, Target, ValueType};
4use serde::{de::DeserializeOwned, Serialize};
5use serde_json::{Map, Value};
6
7/// A scoped handle for operations on a specific target within a session.
8///
9/// Created via [`Session::target()`]. Carries the target, email, and
10/// timestamp from the session so callers never have to pass them.
11///
12/// # Example
13///
14/// ```ignore
15/// let session = Session::discover()?;
16/// let handle = session.target(&Target::parse("commit:abc123")?);
17/// handle.set("agent:model", "claude")?;
18/// let val = handle.get_value("agent:model")?;
19/// ```
20pub struct SessionTargetHandle<'a> {
21 session: &'a Session,
22 target: Target,
23}
24
25impl<'a> SessionTargetHandle<'a> {
26 pub(crate) fn new(session: &'a Session, target: Target) -> Self {
27 Self { session, target }
28 }
29
30 /// Get a metadata value by key.
31 pub fn get_value(&self, key: &str) -> Result<Option<MetaValue>> {
32 self.session.store.get_value(&self.target, key)
33 }
34
35 /// Set a metadata value with convenience conversion.
36 ///
37 /// Accepts anything that converts to [`MetaValue`]: `&str`, `String`,
38 /// `Vec<ListEntry>`, `BTreeSet<String>`, or `MetaValue` directly.
39 ///
40 /// ```ignore
41 /// handle.set("key", "hello")?; // string
42 /// handle.set("key", MetaValue::String("hello".into()))?; // explicit
43 /// ```
44 ///
45 /// Uses the session's email and timestamp automatically.
46 pub fn set(&self, key: &str, value: impl Into<MetaValue>) -> Result<()> {
47 let meta_value = value.into();
48 self.session.store.set_value(
49 &self.target,
50 key,
51 &meta_value,
52 self.session.email(),
53 self.session.now(),
54 )
55 }
56
57 /// Merge string metadata fields under a common key prefix.
58 ///
59 /// The record must serialize to a JSON object. Object field names, including
60 /// `serde` renames, become key suffixes. String values are written as
61 /// `prefix:field`.
62 ///
63 /// This is a partial update, not a replacement operation. Null fields are
64 /// skipped and existing keys are left untouched. This makes `Option<T>`
65 /// fields useful for patch-style records, but callers that need to clear a
66 /// field must remove that key explicitly.
67 ///
68 /// ```ignore
69 /// #[derive(serde::Serialize)]
70 /// #[serde(rename_all = "kebab-case")]
71 /// struct Source<'a> {
72 /// agent: &'a str,
73 /// tool_version: Option<&'a str>,
74 /// }
75 ///
76 /// handle.set_record("agent-session:abc:source:def", &Source {
77 /// agent: "codex",
78 /// tool_version: Some("1.2.3"),
79 /// })?;
80 ///
81 /// // Later updates that serialize `tool_version` as null do not remove the
82 /// // existing `agent-session:abc:source:def:tool-version` key.
83 /// handle.set_record("agent-session:abc:source:def", &Source {
84 /// agent: "codex",
85 /// tool_version: None,
86 /// })?;
87 /// ```
88 pub fn set_record(&self, prefix: &str, record: impl Serialize) -> Result<()> {
89 let Value::Object(fields) = serde_json::to_value(record)? else {
90 return Err(Error::InvalidValue(
91 "record metadata must serialize to a JSON object".to_string(),
92 ));
93 };
94
95 for (field, value) in fields {
96 match value {
97 Value::Null => {}
98 Value::String(value) => self.set(&format!("{prefix}:{field}"), value)?,
99 _ => {
100 return Err(Error::InvalidValue(format!(
101 "record metadata field '{field}' must serialize to a string or null"
102 )));
103 }
104 }
105 }
106
107 Ok(())
108 }
109
110 /// Read string metadata fields under a common key prefix into a record.
111 ///
112 /// This is the read-side pair to [`set_record`](Self::set_record). Immediate
113 /// child keys like `prefix:field` become JSON object fields before being
114 /// deserialized into `T`. Missing records return `Ok(None)`. Nested keys such
115 /// as `prefix:child:field` are ignored because they belong to a deeper
116 /// metadata subtree, not this record.
117 ///
118 /// Because [`set_record`](Self::set_record) leaves null or omitted fields
119 /// untouched, `get_record` reads the current merged field set under the
120 /// prefix, not the exact last value passed to `set_record`.
121 ///
122 /// # Errors
123 ///
124 /// Returns an error if an immediate child field exists but is not a string,
125 /// or if the collected fields do not deserialize into `T`.
126 pub fn get_record<T>(&self, prefix: &str) -> Result<Option<T>>
127 where
128 T: DeserializeOwned,
129 {
130 let field_prefix = format!("{prefix}:");
131 let mut fields = Map::new();
132
133 for (key, value) in self.get_all_values(Some(prefix))? {
134 let Some(field) = key.strip_prefix(&field_prefix) else {
135 continue;
136 };
137 if field.contains(':') {
138 continue;
139 }
140
141 match value {
142 MetaValue::String(value) => {
143 fields.insert(field.to_string(), Value::String(value));
144 }
145 _ => {
146 return Err(Error::InvalidValue(format!(
147 "record metadata field '{field}' must be a string"
148 )));
149 }
150 }
151 }
152
153 if fields.is_empty() {
154 return Ok(None);
155 }
156
157 serde_json::from_value(Value::Object(fields))
158 .map(Some)
159 .map_err(Into::into)
160 }
161
162 /// Remove a metadata key.
163 ///
164 /// Uses the session's email and timestamp automatically.
165 pub fn remove(&self, key: &str) -> Result<bool> {
166 self.session
167 .store
168 .remove(&self.target, key, self.session.email(), self.session.now())
169 }
170
171 /// Push a value onto a list.
172 ///
173 /// Uses the session's email and timestamp automatically.
174 pub fn list_push(&self, key: &str, value: &str) -> Result<()> {
175 self.session.store.list_push(
176 &self.target,
177 key,
178 value,
179 self.session.email(),
180 self.session.now(),
181 )
182 }
183
184 /// Apply list/set edits in one transaction.
185 ///
186 /// Empty edit batches are no-ops. If any edit fails, none of the batch is
187 /// committed. The session email and timestamp are used for every edit.
188 pub fn apply_edits<'b>(
189 &self,
190 edits: impl IntoIterator<Item = crate::MetaEdit<'b>>,
191 ) -> Result<()> {
192 self.session.store.apply_edits(
193 &self.target,
194 edits,
195 self.session.email(),
196 self.session.now(),
197 )
198 }
199
200 /// Pop a value from a list.
201 ///
202 /// Uses the session's email and timestamp automatically.
203 pub fn list_pop(&self, key: &str, value: &str) -> Result<()> {
204 self.session.store.list_pop(
205 &self.target,
206 key,
207 value,
208 self.session.email(),
209 self.session.now(),
210 )
211 }
212
213 /// Remove a list entry by index.
214 ///
215 /// Uses the session's email and timestamp automatically.
216 pub fn list_remove(&self, key: &str, index: usize) -> Result<()> {
217 self.session.store.list_remove(
218 &self.target,
219 key,
220 index,
221 self.session.email(),
222 self.session.now(),
223 )
224 }
225
226 /// Add a member to a set.
227 ///
228 /// Uses the session's email and timestamp automatically.
229 pub fn set_add(&self, key: &str, value: &str) -> Result<()> {
230 self.session.store.set_add(
231 &self.target,
232 key,
233 value,
234 self.session.email(),
235 self.session.now(),
236 )
237 }
238
239 /// Remove a member from a set.
240 ///
241 /// Uses the session's email and timestamp automatically.
242 pub fn set_remove(&self, key: &str, value: &str) -> Result<()> {
243 self.session.store.set_remove(
244 &self.target,
245 key,
246 value,
247 self.session.email(),
248 self.session.now(),
249 )
250 }
251
252 /// The target this handle is scoped to.
253 pub fn target(&self) -> &Target {
254 &self.target
255 }
256
257 /// Get all metadata for this target as typed (key, value) pairs.
258 ///
259 /// Optionally filters by key prefix (e.g., `Some("agent")` returns
260 /// all keys starting with `agent` or `agent:`).
261 ///
262 /// # Parameters
263 ///
264 /// - `prefix`: optional key prefix to filter by
265 ///
266 /// # Returns
267 ///
268 /// A vector of `(key, MetaValue)` pairs for matching metadata entries.
269 ///
270 /// # Errors
271 ///
272 /// Returns an error if the database read or deserialization fails.
273 pub fn get_all_values(&self, prefix: Option<&str>) -> Result<Vec<(String, MetaValue)>> {
274 let entries = self.session.store.get_all(&self.target, prefix)?;
275 let mut result = Vec::with_capacity(entries.len());
276 for entry in entries {
277 let meta_value = match entry.value_type {
278 ValueType::String => {
279 let s: String =
280 serde_json::from_str(&entry.value).unwrap_or_else(|_| entry.value.clone());
281 MetaValue::String(s)
282 }
283 ValueType::List => {
284 let entries = crate::list_value::parse_entries(&entry.value)?;
285 MetaValue::List(entries)
286 }
287 ValueType::Set => {
288 let members: Vec<String> = serde_json::from_str(&entry.value)?;
289 MetaValue::Set(members.into_iter().collect())
290 }
291 };
292 result.push((entry.key, meta_value));
293 }
294 Ok(result)
295 }
296
297 /// Get list entries for a key on this target.
298 ///
299 /// # Parameters
300 ///
301 /// - `key`: the metadata key name
302 ///
303 /// # Returns
304 ///
305 /// A vector of [`ListEntry`](crate::list_value::ListEntry) values with
306 /// resolved content and timestamps.
307 ///
308 /// # Errors
309 ///
310 /// Returns an error if the key is missing, the value is not a list, or
311 /// the database read fails.
312 pub fn list_entries(&self, key: &str) -> Result<Vec<crate::list_value::ListEntry>> {
313 self.session.store.list_entries(&self.target, key)
314 }
315
316 /// Get authorship info (last author email and timestamp) for a key on this target.
317 ///
318 /// # Parameters
319 ///
320 /// - `key`: the metadata key name
321 ///
322 /// # Returns
323 ///
324 /// `Some(Authorship)` if the key has been modified at least once,
325 /// `None` otherwise.
326 ///
327 /// # Errors
328 ///
329 /// Returns an error if the database read fails.
330 pub fn get_authorship(&self, key: &str) -> Result<Option<crate::db::types::Authorship>> {
331 self.session.store.get_authorship(&self.target, key)
332 }
333}