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