1use std::io::{Read, Write};
2use std::path::{Path, PathBuf};
3
4use flate2::read::GzDecoder;
5use flate2::write::GzEncoder;
6use flate2::Compression;
7
8use crate::error::VasariError;
9use crate::schema::{Attribution, AttributionTarget, Node, NodeId};
10
11#[derive(Debug)]
19pub struct ObjectStore {
20 root: PathBuf,
21}
22
23impl ObjectStore {
24 pub const FORMAT_VERSION: &'static str = "2";
29
30 pub fn open(repo_root: &Path) -> Result<Self, VasariError> {
36 let root = repo_root.join(".vasari");
37 let preexisting = root.join("objects").exists();
39 let format_path = root.join("format");
40
41 std::fs::create_dir_all(root.join("objects"))?;
42 std::fs::create_dir_all(root.join("index").join("targets"))?;
43 std::fs::create_dir_all(root.join("refs"))?;
44 if !root.join("HEAD").exists() {
45 std::fs::write(root.join("HEAD"), "")?;
46 }
47
48 if preexisting {
49 let found = std::fs::read_to_string(&format_path)
51 .ok()
52 .map(|s| s.trim().to_string())
53 .filter(|s| !s.is_empty())
54 .unwrap_or_else(|| "1 (pre-v2)".to_string());
55 if found != Self::FORMAT_VERSION {
56 return Err(VasariError::IncompatibleStore {
57 found,
58 expected: Self::FORMAT_VERSION.to_string(),
59 });
60 }
61 } else {
62 std::fs::write(&format_path, Self::FORMAT_VERSION)?;
63 }
64
65 Ok(Self { root })
66 }
67
68 pub fn put(&self, node: &Node) -> Result<NodeId, VasariError> {
70 let id = node.id();
71 let (dir, file) = self.object_path(id)?;
72 if file.exists() {
73 return Ok(id.clone());
74 }
75 std::fs::create_dir_all(&dir)?;
76 let json = serde_json::to_vec(node)?;
77 let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
78 encoder.write_all(&json)?;
79 let compressed = encoder.finish()?;
80 std::fs::write(&file, compressed)?;
81
82 if let Node::Attribution(attr) = node {
84 self.index_attribution(attr)?;
85 }
86
87 Ok(id.clone())
88 }
89
90 pub fn get(&self, id: &NodeId) -> Result<Option<Node>, VasariError> {
92 let (_, file) = self.object_path(id)?;
93 if !file.exists() {
94 return Ok(None);
95 }
96 let compressed = std::fs::read(&file)?;
97 let mut decoder = GzDecoder::new(&compressed[..]);
98 let mut json = Vec::new();
99 decoder.read_to_end(&mut json)?;
100 let node: Node = serde_json::from_slice(&json)?;
101 Ok(Some(node))
102 }
103
104 pub fn lookup_attributions(&self, path: &str, line: u32) -> Result<Vec<NodeId>, VasariError> {
107 let path_dir = self
109 .root
110 .join("index")
111 .join("targets")
112 .join(encode_path(path));
113 if !path_dir.exists() {
114 return Ok(vec![]);
115 }
116 let mut ids = Vec::new();
117 for entry in std::fs::read_dir(&path_dir)? {
118 let entry = entry?;
119 let name = entry.file_name();
120 let name_str = name.to_string_lossy();
121 let covers_line = if name_str == "whole" {
124 true
125 } else if let Some((start_s, end_s)) = name_str.split_once('-') {
126 match (start_s.parse::<u32>(), end_s.parse::<u32>()) {
127 (Ok(start), Ok(end)) => line >= start && line <= end,
128 _ => false,
129 }
130 } else {
131 false
132 };
133 if covers_line {
134 let content = std::fs::read_to_string(entry.path())?;
135 for id_str in content.lines() {
136 if !id_str.is_empty()
138 && id_str.len() >= 4
139 && id_str.chars().all(|c| c.is_ascii_hexdigit())
140 {
141 ids.push(NodeId(id_str.to_string()));
142 }
143 }
144 }
145 }
146 ids.sort_unstable_by(|a, b| a.0.cmp(&b.0));
147 ids.dedup();
148 Ok(ids)
149 }
150
151 pub fn iter_all(&self) -> Result<Vec<Node>, VasariError> {
154 let objects_dir = self.root.join("objects");
155 if !objects_dir.exists() {
156 return Ok(vec![]);
157 }
158 let mut nodes = Vec::new();
159 for prefix_entry in std::fs::read_dir(&objects_dir)? {
160 let prefix_entry = prefix_entry?;
161 let prefix = prefix_entry.file_name().to_string_lossy().to_string();
162 for obj_entry in std::fs::read_dir(prefix_entry.path())? {
163 let obj_entry = obj_entry?;
164 let suffix = obj_entry.file_name().to_string_lossy().to_string();
165 let id = NodeId(format!("{prefix}{suffix}"));
166 match self.get(&id) {
168 Ok(Some(node)) => nodes.push(node),
169 Ok(None) | Err(VasariError::InvalidNodeId(_)) => continue,
170 Err(e) => return Err(e),
171 }
172 }
173 }
174 Ok(nodes)
175 }
176
177 pub fn resolve_prefix(&self, prefix: &str) -> Result<NodeId, VasariError> {
191 if prefix.len() < 4 || !prefix.chars().all(|c| c.is_ascii_hexdigit()) {
192 return Err(VasariError::InvalidNodeId(prefix.to_string()));
193 }
194 let lower = prefix.to_ascii_lowercase();
195 let (shard, rest) = lower.split_at(2);
196 let shard_dir = self.root.join("objects").join(shard);
197 if !shard_dir.exists() {
198 return Err(VasariError::NodeNotFound(prefix.to_string()));
199 }
200
201 let mut matches: Vec<NodeId> = Vec::new();
202 for entry in std::fs::read_dir(&shard_dir)? {
203 let entry = entry?;
204 let fname = entry.file_name().to_string_lossy().to_string();
205 if fname.starts_with(rest) {
206 matches.push(NodeId(format!("{shard}{fname}")));
207 }
208 }
209
210 match matches.len() {
211 0 => Err(VasariError::NodeNotFound(prefix.to_string())),
212 1 => Ok(matches.pop().expect("len checked == 1")),
213 count => Err(VasariError::AmbiguousPrefix {
214 prefix: prefix.to_string(),
215 count,
216 }),
217 }
218 }
219
220 pub fn rebuild_index(&self) -> Result<usize, VasariError> {
222 let objects_dir = self.root.join("objects");
223 let index_targets = self.root.join("index").join("targets");
224 if index_targets.exists() {
225 std::fs::remove_dir_all(&index_targets)?;
226 }
227 std::fs::create_dir_all(&index_targets)?;
228
229 let mut count = 0;
230 for prefix_entry in std::fs::read_dir(&objects_dir)? {
231 let prefix_entry = prefix_entry?;
232 for obj_entry in std::fs::read_dir(prefix_entry.path())? {
233 let obj_entry = obj_entry?;
234 let prefix = prefix_entry.file_name();
235 let suffix = obj_entry.file_name();
236 let id = NodeId(format!(
237 "{}{}",
238 prefix.to_string_lossy(),
239 suffix.to_string_lossy()
240 ));
241 match self.get(&id) {
243 Ok(Some(Node::Attribution(attr))) => {
244 self.index_attribution(&attr)?;
245 count += 1;
246 }
247 Ok(_) | Err(VasariError::InvalidNodeId(_)) => continue,
248 Err(e) => return Err(e),
249 }
250 }
251 }
252 Ok(count)
253 }
254
255 fn object_path(&self, id: &NodeId) -> Result<(PathBuf, PathBuf), VasariError> {
256 let s = id.as_str();
257 if s.len() < 4 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
258 return Err(VasariError::InvalidNodeId(s.to_string()));
259 }
260 let dir = self.root.join("objects").join(&s[..2]);
261 let file = dir.join(&s[2..]);
262 Ok((dir, file))
263 }
264
265 fn index_attribution(&self, attr: &Attribution) -> Result<(), VasariError> {
266 let (path, index_name) = match &attr.target {
269 AttributionTarget::LineRange { path, start, end } => (path, format!("{start}-{end}")),
270 AttributionTarget::WholeFile { path } => (path, "whole".to_string()),
271 AttributionTarget::CommitSha { .. } => return Ok(()),
272 };
273 let path_dir = self
274 .root
275 .join("index")
276 .join("targets")
277 .join(encode_path(path));
278 std::fs::create_dir_all(&path_dir)?;
279 let index_file = path_dir.join(index_name);
280 let mut f = std::fs::OpenOptions::new()
281 .create(true)
282 .append(true)
283 .open(&index_file)?;
284 writeln!(f, "{}", attr.id.as_str())?;
285 Ok(())
286 }
287}
288
289fn encode_path(path: &str) -> String {
295 path.split('/')
302 .map(|component| match component {
303 ".." => "%2E%2E".to_string(),
304 "." => "%2E".to_string(),
305 other => other.replace('%', "%25"),
306 })
307 .collect::<Vec<_>>()
308 .join("%2F")
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314 use crate::schema::{Attribution, AttributionTarget, Intent, Node};
315
316 #[test]
317 fn round_trip_intent() {
318 let dir = tempfile::tempdir().unwrap();
319 let store = ObjectStore::open(dir.path()).unwrap();
320 let intent = Intent::new("ACME-411".into(), "Add JWT verification".into(), vec![]);
321 let id = store.put(&Node::Intent(intent.clone())).unwrap();
322 let got = store.get(&id).unwrap().unwrap();
323 match got {
324 Node::Intent(i) => assert_eq!(i.id, intent.id),
325 _ => panic!("wrong node type"),
326 }
327 }
328
329 #[test]
330 fn put_is_idempotent() {
331 let dir = tempfile::tempdir().unwrap();
332 let store = ObjectStore::open(dir.path()).unwrap();
333 let intent = Intent::new("src".into(), "test".into(), vec![]);
334 let node = Node::Intent(intent);
335 let id1 = store.put(&node).unwrap();
336 let id2 = store.put(&node).unwrap();
337 assert_eq!(id1, id2);
338 }
339
340 #[test]
341 fn attribution_index_lookup() {
342 let dir = tempfile::tempdir().unwrap();
343 let store = ObjectStore::open(dir.path()).unwrap();
344 let action_id = NodeId("deadbeef".repeat(8));
345 let attr = Attribution::new(
346 action_id,
347 AttributionTarget::LineRange {
348 path: "src/auth.ts".into(),
349 start: 40,
350 end: 55,
351 },
352 1.0,
353 vec![],
354 vec![],
355 );
356 let attr_id = attr.id.clone();
357 store.put(&Node::Attribution(attr)).unwrap();
358 let found = store.lookup_attributions("src/auth.ts", 47).unwrap();
359 assert!(found.contains(&attr_id));
360 let not_found = store.lookup_attributions("src/auth.ts", 60).unwrap();
361 assert!(not_found.is_empty());
362 }
363
364 #[test]
365 fn fresh_store_is_stamped_and_reopens() {
366 let dir = tempfile::tempdir().unwrap();
367 let store = ObjectStore::open(dir.path()).unwrap();
368 let version = std::fs::read_to_string(dir.path().join(".vasari").join("format")).unwrap();
369 assert_eq!(version.trim(), ObjectStore::FORMAT_VERSION);
370 drop(store);
371 assert!(ObjectStore::open(dir.path()).is_ok());
373 }
374
375 #[test]
376 fn pre_v2_store_without_format_marker_is_refused() {
377 let dir = tempfile::tempdir().unwrap();
378 std::fs::create_dir_all(dir.path().join(".vasari").join("objects")).unwrap();
380 let err = ObjectStore::open(dir.path()).unwrap_err();
381 match err {
382 VasariError::IncompatibleStore { expected, .. } => {
383 assert_eq!(expected, ObjectStore::FORMAT_VERSION);
384 }
385 other => panic!("expected IncompatibleStore, got {other:?}"),
386 }
387 }
388
389 #[test]
390 fn whole_file_attribution_matches_any_line() {
391 let dir = tempfile::tempdir().unwrap();
392 let store = ObjectStore::open(dir.path()).unwrap();
393 let attr = Attribution::new(
394 NodeId("deadbeef".repeat(8)),
395 AttributionTarget::WholeFile {
396 path: "src/auth.rs".into(),
397 },
398 0.7,
399 vec![],
400 vec![],
401 );
402 let attr_id = attr.id.clone();
403 store.put(&Node::Attribution(attr)).unwrap();
404 for line in [1u32, 47, 9999] {
406 let found = store.lookup_attributions("src/auth.rs", line).unwrap();
407 assert!(
408 found.contains(&attr_id),
409 "whole-file should match line {line}"
410 );
411 }
412 assert!(store
414 .lookup_attributions("src/other.rs", 1)
415 .unwrap()
416 .is_empty());
417 }
418
419 #[test]
420 fn resolve_prefix_unique_match() {
421 let dir = tempfile::tempdir().unwrap();
422 let store = ObjectStore::open(dir.path()).unwrap();
423 let intent = Intent::new("s1".into(), "unique intent".into(), vec![]);
424 let full = store.put(&Node::Intent(intent)).unwrap();
425 let resolved = store.resolve_prefix(&full.as_str()[..10]).unwrap();
426 assert_eq!(resolved, full);
427 assert_eq!(store.resolve_prefix(full.as_str()).unwrap(), full);
429 }
430
431 #[test]
432 fn resolve_prefix_not_found() {
433 let dir = tempfile::tempdir().unwrap();
434 let store = ObjectStore::open(dir.path()).unwrap();
435 store
436 .put(&Node::Intent(Intent::new("s".into(), "x".into(), vec![])))
437 .unwrap();
438 let err = store.resolve_prefix(&"f".repeat(12)).unwrap_err();
440 assert!(matches!(err, VasariError::NodeNotFound(_)));
441 }
442
443 #[test]
444 fn resolve_prefix_rejects_short_and_non_hex_without_panic() {
445 let dir = tempfile::tempdir().unwrap();
446 let store = ObjectStore::open(dir.path()).unwrap();
447 assert!(matches!(
449 store.resolve_prefix("ab").unwrap_err(),
450 VasariError::InvalidNodeId(_)
451 ));
452 assert!(matches!(
454 store.resolve_prefix("").unwrap_err(),
455 VasariError::InvalidNodeId(_)
456 ));
457 assert!(matches!(
459 store.resolve_prefix("zzzz").unwrap_err(),
460 VasariError::InvalidNodeId(_)
461 ));
462 }
463
464 #[test]
465 fn resolve_prefix_ambiguous_reports_count() {
466 let dir = tempfile::tempdir().unwrap();
467 let store = ObjectStore::open(dir.path()).unwrap();
468 for suffix in ["aaaa", "bbbb"] {
470 let id = NodeId(format!("abcdef{}", suffix.repeat(14)));
471 let (dir_p, file_p) = store.object_path(&id).unwrap();
472 std::fs::create_dir_all(&dir_p).unwrap();
473 std::fs::write(&file_p, b"x").unwrap();
474 }
475 let err = store.resolve_prefix("abcdef").unwrap_err();
476 match err {
477 VasariError::AmbiguousPrefix { count, .. } => assert_eq!(count, 2),
478 other => panic!("expected AmbiguousPrefix, got {other:?}"),
479 }
480 }
481
482 #[test]
483 fn iter_all_on_empty_store_returns_empty() {
484 let dir = tempfile::tempdir().unwrap();
485 let store = ObjectStore::open(dir.path()).unwrap();
486 let nodes = store.iter_all().unwrap();
487 assert!(nodes.is_empty());
488 }
489
490 #[test]
491 fn iter_all_returns_all_stored_nodes() {
492 let dir = tempfile::tempdir().unwrap();
493 let store = ObjectStore::open(dir.path()).unwrap();
494 let i1 = Intent::new("s1".into(), "first intent".into(), vec![]);
495 let i2 = Intent::new("s2".into(), "second intent".into(), vec![]);
496 store.put(&Node::Intent(i1.clone())).unwrap();
497 store.put(&Node::Intent(i2.clone())).unwrap();
498 let nodes = store.iter_all().unwrap();
499 assert_eq!(nodes.len(), 2);
500 }
501
502 #[test]
503 fn rebuild_index_restores_attribution_lookup() {
504 let dir = tempfile::tempdir().unwrap();
505 let store = ObjectStore::open(dir.path()).unwrap();
506 let action_id = NodeId("deadbeef".repeat(8));
507 let attr = Attribution::new(
508 action_id,
509 AttributionTarget::LineRange {
510 path: "src/lib.rs".into(),
511 start: 1,
512 end: u32::MAX,
513 },
514 0.9,
515 vec![],
516 vec![],
517 );
518 let attr_id = attr.id.clone();
519 store.put(&Node::Attribution(attr)).unwrap();
520
521 let index_dir = dir.path().join(".vasari").join("index").join("targets");
523 std::fs::remove_dir_all(&index_dir).unwrap();
524 std::fs::create_dir_all(&index_dir).unwrap();
525
526 let before = store.lookup_attributions("src/lib.rs", 42).unwrap();
528 assert!(before.is_empty());
529
530 let count = store.rebuild_index().unwrap();
532 assert_eq!(count, 1);
533
534 let after = store.lookup_attributions("src/lib.rs", 42).unwrap();
536 assert!(after.contains(&attr_id));
537 }
538
539 #[test]
540 fn lookup_attributions_deduplicates_on_re_ingest() {
541 let dir = tempfile::tempdir().unwrap();
542 let store = ObjectStore::open(dir.path()).unwrap();
543 let action_id = NodeId("deadbeef".repeat(8));
544 let attr = Attribution::new(
545 action_id,
546 AttributionTarget::LineRange {
547 path: "src/dup.rs".into(),
548 start: 1,
549 end: 100,
550 },
551 1.0,
552 vec![],
553 vec![],
554 );
555 let attr_id = attr.id.clone();
556 store.put(&Node::Attribution(attr.clone())).unwrap();
558 let encoded = encode_path("src/dup.rs");
560 let index_file = dir
561 .path()
562 .join(".vasari")
563 .join("index")
564 .join("targets")
565 .join(&encoded)
566 .join("1-100");
567 let mut f = std::fs::OpenOptions::new()
568 .append(true)
569 .open(&index_file)
570 .unwrap();
571 use std::io::Write;
572 writeln!(f, "{}", attr_id.as_str()).unwrap();
573
574 let found = store.lookup_attributions("src/dup.rs", 50).unwrap();
575 assert_eq!(found.len(), 1, "dedup-on-read should remove the duplicate");
576 }
577
578 #[test]
579 fn encode_path_encodes_slashes() {
580 let encoded = encode_path("src/auth/mod.rs");
581 assert!(!encoded.contains('/'));
582 assert!(encoded.contains("%2F"));
583 }
584
585 #[test]
586 fn encode_path_encodes_dotdot() {
587 let encoded = encode_path("../escape/path.rs");
588 assert!(!encoded.contains(".."));
589 assert!(encoded.contains("%2E%2E"));
590 }
591
592 #[test]
593 fn encode_path_encodes_single_dot() {
594 let encoded = encode_path("./relative.rs");
595 assert!(
596 encoded.contains("%2E"),
597 "single dot should be percent-encoded"
598 );
599 assert!(
600 !encoded.contains(".."),
601 "single dot should not be mistaken for dotdot"
602 );
603 }
604
605 #[test]
606 fn encode_path_encodes_percent() {
607 let encoded = encode_path("src/100%done.rs");
608 assert!(encoded.contains("%25"));
609 }
610
611 #[test]
612 fn lookup_attributions_returns_empty_for_unknown_path() {
613 let dir = tempfile::tempdir().unwrap();
614 let store = ObjectStore::open(dir.path()).unwrap();
615 let result = store.lookup_attributions("nonexistent/file.rs", 1).unwrap();
616 assert!(result.is_empty());
617 }
618}