handoff-mcp-server 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,7 @@ pub mod list_sessions;
15
15
  pub mod list_tasks;
16
16
  pub mod load_context;
17
17
  pub mod log_time;
18
+ pub mod memory;
18
19
  pub mod metrics;
19
20
  pub mod milestones;
20
21
  pub mod refer;
@@ -74,6 +75,10 @@ pub fn handle_tool_call(name: &str, arguments: &Value) -> JsonRpcResponse {
74
75
  "handoff_update_calendar" => calendar::handle_update_calendar(arguments),
75
76
  "handoff_update_labels" => calendar::handle_update_labels(arguments),
76
77
  "handoff_start_project" => calendar::handle_start_project(arguments),
78
+ "memory_save" => memory::handle_save(arguments),
79
+ "memory_query" => memory::handle_query(arguments),
80
+ "memory_delete" => memory::handle_delete(arguments),
81
+ "memory_cleanup" => memory::handle_cleanup(arguments),
77
82
  _ => Err(anyhow::anyhow!("Tool not implemented: {name}")),
78
83
  };
79
84
 
package/src/mcp/tools.rs CHANGED
@@ -1023,6 +1023,65 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
1023
1023
  }
1024
1024
  }),
1025
1025
  },
1026
+ ToolDefinition {
1027
+ name: "memory_save".to_string(),
1028
+ description: "Save a long-lived project memory (lesson/rule/convention/gotcha) that future sessions should respect. Detects exact and near-duplicate memories: an exact match is reported (not rewritten), a near-duplicate is returned as a 'conflict' with both bodies for you to merge (call again with merge_into=<id> and absorb_ids=[…]) or save separately with force=true. Returns a JSON string.".to_string(),
1029
+ input_schema: json!({
1030
+ "type": "object",
1031
+ "properties": {
1032
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1033
+ "text": { "type": "string", "description": "The memory body (any language). Required, non-empty." },
1034
+ "kind": { "type": "string", "description": "Memory kind.", "enum": ["lesson", "rule", "convention", "gotcha"], "default": "lesson" },
1035
+ "tags": { "type": "array", "items": { "type": "string" }, "description": "Optional tags; also indexed for similarity." },
1036
+ "scope_paths": { "type": "array", "items": { "type": "string" }, "description": "Path prefixes this memory applies to (e.g. 'src/storage/'). Boosts relevance when a query touches a matching file." },
1037
+ "merge_into": { "type": "string", "description": "Commit an AI merge: overwrite this memory id with `text` and absorb `absorb_ids`." },
1038
+ "absorb_ids": { "type": "array", "items": { "type": "string" }, "description": "Memory ids to delete and record as superseded when merging." },
1039
+ "force": { "type": "boolean", "description": "Save even if a near-duplicate exists (skip the conflict response).", "default": false }
1040
+ },
1041
+ "required": ["text"]
1042
+ }),
1043
+ },
1044
+ ToolDefinition {
1045
+ name: "memory_query".to_string(),
1046
+ description: "Return the project memories most relevant to the given text/file (BM25 + scope-path boosting). Intended for automatic injection via hooks, but callable directly. Returns a JSON string {\"memories\":[{id,text,kind,score}],\"injected_count\"}.".to_string(),
1047
+ input_schema: json!({
1048
+ "type": "object",
1049
+ "properties": {
1050
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1051
+ "session_id": { "type": "string", "description": "Hook session id. When given, memories already injected this session (same content hash) are filtered out; an edited memory is re-injected." },
1052
+ "text": { "type": "string", "description": "The current prompt or context text to match against." },
1053
+ "tool_name": { "type": "string", "description": "Name of the tool about to run (e.g. 'Edit'); added to the query." },
1054
+ "file_paths": { "type": "array", "items": { "type": "string" }, "description": "File paths in play; basenames are added to the query and scope_paths are matched against these." },
1055
+ "limit": { "type": "integer", "description": "Maximum memories to return.", "default": 5 },
1056
+ "mark_injected": { "type": "boolean", "description": "Record returned memories in the session sidecar and bump their hit_count/last_referenced_at. Requires session_id.", "default": true }
1057
+ },
1058
+ "required": ["text"]
1059
+ }),
1060
+ },
1061
+ ToolDefinition {
1062
+ name: "memory_delete".to_string(),
1063
+ description: "Delete a project memory by id (full id or unique prefix). Use for AI-driven cleanup of stale memories. Returns a JSON string {\"status\":\"deleted\",\"id\"}.".to_string(),
1064
+ input_schema: json!({
1065
+ "type": "object",
1066
+ "properties": {
1067
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1068
+ "id": { "type": "string", "description": "Memory id to delete (full id or unique prefix)." }
1069
+ },
1070
+ "required": ["id"]
1071
+ }),
1072
+ },
1073
+ ToolDefinition {
1074
+ name: "memory_cleanup".to_string(),
1075
+ description: "Housekeep the project memory store (intended for SessionStart). Silently merges exact duplicates (lossless), then returns recommendations the AI should act on: near-duplicate clusters (merge with memory_save merge_into=…) and stale memories (consider memory_delete). Also garbage-collects old per-session injection sidecars. Returns a JSON string {\"auto_merged_exact\":n,\"cleanup_recommendations\":{\"similar_clusters\":[…],\"stale\":[…]},\"injected_sidecars_removed\":k}.".to_string(),
1076
+ input_schema: json!({
1077
+ "type": "object",
1078
+ "properties": {
1079
+ "project_dir": { "type": "string", "description": "Project directory path. Defaults to current working directory." },
1080
+ "apply_exact_merges": { "type": "boolean", "description": "Auto-merge exact-duplicate memories (same content hash). Lossless and safe.", "default": true },
1081
+ "stale_days": { "type": "integer", "description": "Flag memories not referenced for this many days as stale recommendations.", "default": 60 }
1082
+ }
1083
+ }),
1084
+ },
1026
1085
  ]
1027
1086
  }
1028
1087
 
@@ -58,6 +58,28 @@ pub struct SettingsConfig {
58
58
  pub ai_estimate_multiplier: f64,
59
59
  #[serde(default)]
60
60
  pub context_files: Vec<String>,
61
+ /// Master switch for the memory feature (save/query/cleanup). Default true.
62
+ #[serde(default = "default_memory_enabled")]
63
+ pub memory_enabled: bool,
64
+ /// Jaccard threshold above which `memory_save` treats a save as a
65
+ /// near-duplicate `conflict` for the AI to merge. Default 0.72.
66
+ #[serde(default = "default_memory_dup_threshold")]
67
+ pub memory_dup_threshold: f64,
68
+ /// BM25 relevance floor for `memory_query`; scores below are not returned.
69
+ /// Default 0.5.
70
+ #[serde(default = "default_memory_query_min_score")]
71
+ pub memory_query_min_score: f64,
72
+ /// Maximum number of memories `memory_query` returns per call. Default 5.
73
+ #[serde(default = "default_memory_query_limit")]
74
+ pub memory_query_limit: u32,
75
+ /// Days after which an un(re)referenced memory is flagged `stale` by
76
+ /// `memory_cleanup`. Default 60.
77
+ #[serde(default = "default_memory_stale_days")]
78
+ pub memory_stale_days: i64,
79
+ /// Age (days) past which `memory_cleanup` garbage-collects a per-session
80
+ /// `injected/` sidecar. Default 14.
81
+ #[serde(default = "default_memory_injected_gc_days")]
82
+ pub memory_injected_gc_days: i64,
61
83
  #[serde(default)]
62
84
  pub custom_fields: HashMap<String, toml::Value>,
63
85
  }
@@ -205,6 +227,30 @@ fn default_ai_estimate_multiplier() -> f64 {
205
227
  0.2
206
228
  }
207
229
 
230
+ fn default_memory_enabled() -> bool {
231
+ true
232
+ }
233
+
234
+ fn default_memory_dup_threshold() -> f64 {
235
+ 0.72
236
+ }
237
+
238
+ fn default_memory_query_min_score() -> f64 {
239
+ 0.5
240
+ }
241
+
242
+ fn default_memory_query_limit() -> u32 {
243
+ 5
244
+ }
245
+
246
+ fn default_memory_stale_days() -> i64 {
247
+ 60
248
+ }
249
+
250
+ fn default_memory_injected_gc_days() -> i64 {
251
+ 14
252
+ }
253
+
208
254
  fn default_scan_dirs() -> Vec<String> {
209
255
  vec!["~/pro/".to_string()]
210
256
  }
@@ -218,6 +264,12 @@ impl Default for SettingsConfig {
218
264
  require_estimate_hours: default_require_estimate_hours(),
219
265
  ai_estimate_multiplier: default_ai_estimate_multiplier(),
220
266
  context_files: Vec::new(),
267
+ memory_enabled: default_memory_enabled(),
268
+ memory_dup_threshold: default_memory_dup_threshold(),
269
+ memory_query_min_score: default_memory_query_min_score(),
270
+ memory_query_limit: default_memory_query_limit(),
271
+ memory_stale_days: default_memory_stale_days(),
272
+ memory_injected_gc_days: default_memory_injected_gc_days(),
221
273
  custom_fields: HashMap::new(),
222
274
  }
223
275
  }
@@ -0,0 +1,340 @@
1
+ //! Per-session "already injected" sidecars under `.handoff/memory/injected/`.
2
+ //!
3
+ //! ```text
4
+ //! .handoff/memory/injected/
5
+ //! <hook_session_id>.json # { session_id, updated_at, injected: { id -> hash } }
6
+ //! ```
7
+ //!
8
+ //! The sidecar is keyed by the **hook's `session_id`** (not the timestamped
9
+ //! session file): the hook never sees a session filename, and the id is stable
10
+ //! across compaction/resume but changes on `/clear` — which gives us a free
11
+ //! per-conversation reset.
12
+ //!
13
+ //! A memory's value is its `content_hash`. `memory_query` skips a memory whose
14
+ //! id is present with the *same* hash (already injected this session) but
15
+ //! re-injects when the hash differs (the memory was edited since). All writes go
16
+ //! through [`crate::storage::atomic_write`] so a concurrent reader never sees a
17
+ //! torn file; reads are lenient (a corrupt sidecar is treated as empty).
18
+
19
+ use std::collections::BTreeMap;
20
+ use std::path::{Path, PathBuf};
21
+
22
+ use anyhow::{Context, Result};
23
+ use serde::{Deserialize, Serialize};
24
+
25
+ use super::memory_dir;
26
+
27
+ /// Current sidecar schema version.
28
+ pub const INJECTED_SCHEMA_VERSION: u32 = 1;
29
+
30
+ /// The set of memories already injected into one hook session.
31
+ ///
32
+ /// `injected` maps memory id → the `content_hash` that was injected, so an edited
33
+ /// memory (new hash) is re-injected while an unchanged one is skipped.
34
+ #[derive(Debug, Clone, Serialize, Deserialize)]
35
+ pub struct InjectedSet {
36
+ /// Schema version (= [`INJECTED_SCHEMA_VERSION`]).
37
+ #[serde(default = "default_version")]
38
+ pub version: u32,
39
+ /// The hook session id this sidecar belongs to.
40
+ pub session_id: String,
41
+ /// RFC3339 timestamp of the last update.
42
+ pub updated_at: String,
43
+ /// memory id → injected `content_hash`.
44
+ #[serde(default)]
45
+ pub injected: BTreeMap<String, String>,
46
+ }
47
+
48
+ fn default_version() -> u32 {
49
+ INJECTED_SCHEMA_VERSION
50
+ }
51
+
52
+ impl InjectedSet {
53
+ /// A fresh, empty set for `session_id`.
54
+ pub fn new(session_id: String, now: String) -> Self {
55
+ InjectedSet {
56
+ version: INJECTED_SCHEMA_VERSION,
57
+ session_id,
58
+ updated_at: now,
59
+ injected: BTreeMap::new(),
60
+ }
61
+ }
62
+
63
+ /// True if `id` was already injected this session with the *same* hash. A
64
+ /// differing hash (edited memory) returns false so it is re-injected.
65
+ pub fn already_injected(&self, id: &str, content_hash: &str) -> bool {
66
+ self.injected.get(id).map(String::as_str) == Some(content_hash)
67
+ }
68
+
69
+ /// Record `id`→`content_hash` as injected. Returns true if this changed the
70
+ /// set (new id or a different hash), false if it was already present.
71
+ pub fn mark(&mut self, id: &str, content_hash: &str) -> bool {
72
+ match self.injected.get(id) {
73
+ Some(existing) if existing == content_hash => false,
74
+ _ => {
75
+ self.injected
76
+ .insert(id.to_string(), content_hash.to_string());
77
+ true
78
+ }
79
+ }
80
+ }
81
+ }
82
+
83
+ /// Path to the `injected/` directory inside a `memory/` dir.
84
+ pub fn injected_dir(handoff_dir: &Path) -> PathBuf {
85
+ memory_dir(handoff_dir).join("injected")
86
+ }
87
+
88
+ /// Sanitize a hook session id into a safe, **collision-free** single-path
89
+ /// file stem.
90
+ ///
91
+ /// Session ids are opaque strings from the hook environment; we must never let
92
+ /// one escape `injected/` (path traversal) or collide with the temp-file naming
93
+ /// in `atomic_write`. Two requirements:
94
+ ///
95
+ /// 1. **Safety**: the stem is a single path component — no `/`, `\`, `..`, or
96
+ /// leading `.` can survive (each unsafe char becomes `_`, leading dots are
97
+ /// dropped).
98
+ /// 2. **Uniqueness**: the readable part alone is lossy (`a/b` and `a_b` would
99
+ /// map to the same `a_b`), so we always append `-<fnv1a(raw id)>`. Distinct
100
+ /// raw ids therefore never share a sidecar, while the stem stays
101
+ /// human-recognizable. The hash is over the *raw* id, so it disambiguates
102
+ /// even when the readable prefix is identical or empty.
103
+ fn sanitize_session_id(session_id: &str) -> String {
104
+ let mut out = String::with_capacity(session_id.len());
105
+ for ch in session_id.chars() {
106
+ if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-') {
107
+ out.push(ch);
108
+ } else if ch == '.' {
109
+ // Keep dots only when not leading (avoid hidden files / `..`).
110
+ if !out.is_empty() {
111
+ out.push('.');
112
+ }
113
+ } else {
114
+ out.push('_');
115
+ }
116
+ }
117
+ // Trim trailing dots so we never produce `name.`, and cap the readable part.
118
+ let trimmed = out.trim_end_matches('.');
119
+ let prefix: String = trimmed.chars().take(96).collect();
120
+ let prefix = if prefix.is_empty() { "anon" } else { &prefix };
121
+ // Always disambiguate with a hash of the RAW id (std-only FNV-1a).
122
+ format!("{prefix}-{}", lexsim::fnv1a_hex(session_id.as_bytes()))
123
+ }
124
+
125
+ /// Path to one session's sidecar file.
126
+ pub fn injected_path(handoff_dir: &Path, session_id: &str) -> PathBuf {
127
+ injected_dir(handoff_dir).join(format!("{}.json", sanitize_session_id(session_id)))
128
+ }
129
+
130
+ /// Read the injected set for `session_id`. Returns a fresh empty set when the
131
+ /// file does not exist or is unparseable (lenient: a corrupt sidecar must not
132
+ /// break injection — at worst a memory is shown twice).
133
+ pub fn read_injected_set(handoff_dir: &Path, session_id: &str, now: &str) -> InjectedSet {
134
+ let path = injected_path(handoff_dir, session_id);
135
+ match std::fs::read_to_string(&path) {
136
+ Ok(content) => serde_json::from_str::<InjectedSet>(&content)
137
+ .unwrap_or_else(|_| InjectedSet::new(session_id.to_string(), now.to_string())),
138
+ Err(_) => InjectedSet::new(session_id.to_string(), now.to_string()),
139
+ }
140
+ }
141
+
142
+ /// Write a session's injected set atomically, creating `injected/` lazily.
143
+ pub fn write_injected_set(handoff_dir: &Path, set: &InjectedSet) -> Result<PathBuf> {
144
+ let dir = injected_dir(handoff_dir);
145
+ std::fs::create_dir_all(&dir)
146
+ .with_context(|| format!("Failed to create injected dir: {}", dir.display()))?;
147
+ let path = injected_path(handoff_dir, &set.session_id);
148
+ let content = serde_json::to_string_pretty(set).context("Failed to serialize injected set")?;
149
+ crate::storage::atomic_write(&path, content.as_bytes())
150
+ .with_context(|| format!("Failed to write injected set: {}", path.display()))?;
151
+ Ok(path)
152
+ }
153
+
154
+ /// Garbage-collect sidecars whose `updated_at` is older than `max_age_days`.
155
+ /// Returns the number of files removed. A sidecar with an unparseable
156
+ /// `updated_at` is left alone (we can't tell its age). `now` is supplied so the
157
+ /// caller controls the clock (testable).
158
+ pub fn gc_injected_sets(
159
+ handoff_dir: &Path,
160
+ max_age_days: i64,
161
+ now: chrono::DateTime<chrono::Utc>,
162
+ ) -> Result<usize> {
163
+ let dir = injected_dir(handoff_dir);
164
+ if !dir.exists() {
165
+ return Ok(0);
166
+ }
167
+ let cutoff = now - chrono::Duration::days(max_age_days);
168
+ let mut removed = 0usize;
169
+ for entry in std::fs::read_dir(&dir)
170
+ .with_context(|| format!("Failed to read injected dir: {}", dir.display()))?
171
+ .filter_map(|e| e.ok())
172
+ {
173
+ let path = entry.path();
174
+ if !path.is_file() {
175
+ continue;
176
+ }
177
+ let name = entry.file_name().to_string_lossy().to_string();
178
+ if !name.ends_with(".json") || name.starts_with('.') {
179
+ continue;
180
+ }
181
+ let Ok(content) = std::fs::read_to_string(&path) else {
182
+ continue;
183
+ };
184
+ let Ok(set) = serde_json::from_str::<InjectedSet>(&content) else {
185
+ continue; // unparseable → leave it (can't determine age)
186
+ };
187
+ let Ok(updated) = chrono::DateTime::parse_from_rfc3339(&set.updated_at) else {
188
+ continue; // bad timestamp → leave it
189
+ };
190
+ if updated.with_timezone(&chrono::Utc) < cutoff && std::fs::remove_file(&path).is_ok() {
191
+ removed += 1;
192
+ }
193
+ }
194
+ Ok(removed)
195
+ }
196
+
197
+ #[cfg(test)]
198
+ mod tests {
199
+ use super::*;
200
+ use tempfile::TempDir;
201
+
202
+ fn handoff(tmp: &TempDir) -> PathBuf {
203
+ let dir = tmp.path().join(".handoff");
204
+ std::fs::create_dir_all(&dir).unwrap();
205
+ dir
206
+ }
207
+
208
+ #[test]
209
+ fn read_missing_is_empty() {
210
+ let tmp = TempDir::new().unwrap();
211
+ let h = handoff(&tmp);
212
+ let set = read_injected_set(&h, "sess-1", "2026-06-27T00:00:00Z");
213
+ assert_eq!(set.session_id, "sess-1");
214
+ assert!(set.injected.is_empty());
215
+ }
216
+
217
+ #[test]
218
+ fn write_then_read_roundtrip() {
219
+ let tmp = TempDir::new().unwrap();
220
+ let h = handoff(&tmp);
221
+ let mut set = InjectedSet::new("sess-1".to_string(), "2026-06-27T00:00:00Z".to_string());
222
+ assert!(set.mark("m-1", "hashA"));
223
+ write_injected_set(&h, &set).unwrap();
224
+
225
+ let back = read_injected_set(&h, "sess-1", "now");
226
+ assert_eq!(back.injected.get("m-1").map(String::as_str), Some("hashA"));
227
+ }
228
+
229
+ #[test]
230
+ fn already_injected_same_hash_true_diff_hash_false() {
231
+ let mut set = InjectedSet::new("s".to_string(), "now".to_string());
232
+ set.mark("m-1", "hashA");
233
+ assert!(set.already_injected("m-1", "hashA"));
234
+ assert!(!set.already_injected("m-1", "hashB")); // edited → re-inject
235
+ assert!(!set.already_injected("m-2", "hashA")); // never injected
236
+ }
237
+
238
+ #[test]
239
+ fn mark_reports_change() {
240
+ let mut set = InjectedSet::new("s".to_string(), "now".to_string());
241
+ assert!(set.mark("m-1", "h1")); // new id
242
+ assert!(!set.mark("m-1", "h1")); // unchanged
243
+ assert!(set.mark("m-1", "h2")); // hash changed
244
+ }
245
+
246
+ #[test]
247
+ fn sessions_are_isolated() {
248
+ let tmp = TempDir::new().unwrap();
249
+ let h = handoff(&tmp);
250
+ let mut a = InjectedSet::new("A".to_string(), "now".to_string());
251
+ a.mark("m-1", "h");
252
+ write_injected_set(&h, &a).unwrap();
253
+
254
+ let b = read_injected_set(&h, "B", "now");
255
+ assert!(
256
+ b.injected.is_empty(),
257
+ "session B must not see session A's set"
258
+ );
259
+ }
260
+
261
+ #[test]
262
+ fn sanitize_blocks_traversal_and_separators() {
263
+ // Path separators are neutralized so the result is always a single flat
264
+ // filename component — no traversal possible.
265
+ for evil in [
266
+ "../../etc/passwd",
267
+ "a/b\\c",
268
+ "..",
269
+ "../",
270
+ "foo/../bar",
271
+ ".hidden",
272
+ "",
273
+ "...",
274
+ ] {
275
+ let s = sanitize_session_id(evil);
276
+ assert!(!s.contains('/'), "{evil:?} -> {s:?} still has /");
277
+ assert!(!s.contains('\\'), "{evil:?} -> {s:?} still has \\");
278
+ assert_ne!(s, "..", "{evil:?} -> {s:?} is parent ref");
279
+ assert!(!s.starts_with('.'), "{evil:?} -> {s:?} is hidden");
280
+ assert!(!s.is_empty(), "{evil:?} -> empty stem");
281
+ }
282
+ // The readable prefix is preserved; a hash suffix is always appended.
283
+ assert!(sanitize_session_id("3f9a-1b2c-4d5e").starts_with("3f9a-1b2c-4d5e-"));
284
+ assert!(sanitize_session_id(".hidden").starts_with("hidden-"));
285
+ }
286
+
287
+ #[test]
288
+ fn sanitize_is_collision_free_for_distinct_ids() {
289
+ // Lossy normalization alone would collide these; the raw-id hash suffix
290
+ // keeps every distinct id on its own sidecar.
291
+ let pairs = [("a/b", "a_b"), ("...", ""), ("a.b", "a_b"), ("X/Y", "X.Y")];
292
+ for (x, y) in pairs {
293
+ assert_ne!(
294
+ sanitize_session_id(x),
295
+ sanitize_session_id(y),
296
+ "{x:?} and {y:?} must not share a sidecar"
297
+ );
298
+ }
299
+ // Same id is stable (round-trips to the same file).
300
+ assert_eq!(sanitize_session_id("sess-A"), sanitize_session_id("sess-A"));
301
+ }
302
+
303
+ #[test]
304
+ fn lenient_read_treats_corrupt_as_empty() {
305
+ let tmp = TempDir::new().unwrap();
306
+ let h = handoff(&tmp);
307
+ let dir = injected_dir(&h);
308
+ std::fs::create_dir_all(&dir).unwrap();
309
+ std::fs::write(injected_path(&h, "sess-1"), b"{not json").unwrap();
310
+
311
+ let set = read_injected_set(&h, "sess-1", "now");
312
+ assert!(set.injected.is_empty());
313
+ }
314
+
315
+ #[test]
316
+ fn gc_removes_old_keeps_fresh() {
317
+ let tmp = TempDir::new().unwrap();
318
+ let h = handoff(&tmp);
319
+ let old = InjectedSet::new("old".to_string(), "2026-01-01T00:00:00Z".to_string());
320
+ let fresh = InjectedSet::new("fresh".to_string(), "2026-06-27T00:00:00Z".to_string());
321
+ write_injected_set(&h, &old).unwrap();
322
+ write_injected_set(&h, &fresh).unwrap();
323
+
324
+ let now = chrono::DateTime::parse_from_rfc3339("2026-06-28T00:00:00Z")
325
+ .unwrap()
326
+ .with_timezone(&chrono::Utc);
327
+ let removed = gc_injected_sets(&h, 14, now).unwrap();
328
+ assert_eq!(removed, 1, "only the January sidecar is past 14 days");
329
+ assert!(read_injected_set(&h, "old", "now").injected.is_empty());
330
+ assert!(injected_path(&h, "fresh").exists());
331
+ }
332
+
333
+ #[test]
334
+ fn gc_on_missing_dir_is_zero() {
335
+ let tmp = TempDir::new().unwrap();
336
+ let h = handoff(&tmp);
337
+ let now = chrono::Utc::now();
338
+ assert_eq!(gc_injected_sets(&h, 14, now).unwrap(), 0);
339
+ }
340
+ }