1use crate::Error;
2use serde::Serialize;
3use stakpak_api::stakpak::{
4 KnowledgeApiError, ListKnowledgeFilesQuery, StakpakApiClient, StakpakApiConfig,
5};
6use std::cmp::Ordering;
7use std::fs;
8use std::io::ErrorKind;
9use std::path::{Component, Path, PathBuf};
10use walkdir::WalkDir;
11
12fn map_knowledge_err(path: &str, err: KnowledgeApiError) -> Error {
17 match err {
18 KnowledgeApiError::NotFound { .. } => Error::NotFound(PathBuf::from(path)),
19 KnowledgeApiError::Conflict { .. } => Error::AlreadyExists(PathBuf::from(path)),
20 other => Error::Parse(other.to_string()),
21 }
22}
23
24pub trait StorageBackend {
25 fn create(&self, path: &str, content: &[u8]) -> Result<(), Error>;
26 fn overwrite(&self, path: &str, content: &[u8]) -> Result<(), Error>;
27 fn read(&self, path: &str) -> Result<Vec<u8>, Error>;
28 fn read_prefix(&self, path: &str, max_bytes: usize) -> Result<Vec<u8>, Error>;
29 fn remove(&self, path: &str) -> Result<(), Error>;
30 fn list(&self, path: &str) -> Result<Vec<Entry>, Error>;
31 fn tree(&self, prefix: &str) -> Result<TreeNode, Error>;
35 fn walk(&self, prefix: &str) -> Result<Vec<String>, Error>;
38 fn exists(&self, path: &str) -> Result<bool, Error>;
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
42pub struct Entry {
43 pub name: String,
44 pub is_dir: bool,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
48pub struct TreeNode {
49 pub name: String,
50 pub is_dir: bool,
51 pub children: Vec<TreeNode>,
52}
53
54impl TreeNode {
55 pub fn print(&self) -> String {
56 let mut lines = vec![self.name.clone()];
57 self.render_children("", &mut lines);
58 lines.join("\n")
59 }
60
61 fn render_children(&self, prefix: &str, lines: &mut Vec<String>) {
62 let last_index = self.children.len().saturating_sub(1);
63
64 for (index, child) in self.children.iter().enumerate() {
65 let connector = if index == last_index {
66 "└──"
67 } else {
68 "├──"
69 };
70 lines.push(format!("{prefix}{connector} {}", child.name));
71
72 let next_prefix = if index == last_index {
73 format!("{prefix} ")
74 } else {
75 format!("{prefix}│ ")
76 };
77 child.render_children(&next_prefix, lines);
78 }
79 }
80}
81
82#[derive(Debug, Clone)]
83pub struct LocalFsBackend {
84 root: PathBuf,
85}
86
87impl LocalFsBackend {
88 fn relative_path(&self, path: &Path) -> PathBuf {
90 path.strip_prefix(&self.root)
91 .map(PathBuf::from)
92 .unwrap_or_else(|_| path.to_path_buf())
93 }
94
95 pub fn new() -> Result<Self, Error> {
96 if let Some(root) = std::env::var_os("AK_STORE") {
97 return Ok(Self {
98 root: PathBuf::from(root),
99 });
100 }
101
102 let home = dirs::home_dir()
103 .ok_or_else(|| Error::Parse("could not determine home directory".to_string()))?;
104 Ok(Self {
105 root: default_store_root(&home),
106 })
107 }
108
109 pub fn with_root(root: PathBuf) -> Self {
110 Self { root }
111 }
112
113 pub fn root(&self) -> &Path {
114 &self.root
115 }
116
117 pub fn file_count(&self) -> Result<usize, Error> {
118 if !self.root.exists() {
119 return Ok(0);
120 }
121
122 let mut count = 0;
123 for entry in WalkDir::new(&self.root)
124 .into_iter()
125 .filter_entry(|entry| !is_hidden_path(entry.path(), &self.root))
126 {
127 let entry = entry.map_err(|error| Error::Io(std::io::Error::other(error)))?;
128 if entry.path() != self.root && entry.file_type().is_symlink() {
129 return Err(Error::UnsafePath(self.relative_path(entry.path())));
130 }
131 if entry.file_type().is_file() {
132 count += 1;
133 }
134 }
135
136 Ok(count)
137 }
138
139 fn ensure_store(&self) -> Result<(), Error> {
140 fs::create_dir_all(&self.root)?;
141 Ok(())
142 }
143
144 fn resolve_path(&self, path: &str) -> Result<PathBuf, Error> {
145 if path.is_empty() {
146 return Ok(self.root.clone());
147 }
148
149 let mut relative = PathBuf::new();
150 for component in Path::new(path).components() {
151 match component {
152 Component::Normal(part) => relative.push(part),
153 Component::CurDir => {}
154 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
155 return Err(Error::Parse(format!("invalid store path: {path}")));
156 }
157 }
158 }
159
160 Ok(self.root.join(relative))
161 }
162
163 fn ensure_no_symlinks_below_root(&self, path: &Path) -> Result<(), Error> {
164 let relative = path.strip_prefix(&self.root).map_err(|_| {
165 Error::Parse(format!(
166 "path is outside the configured store root: {}",
167 self.relative_path(path).display()
168 ))
169 })?;
170
171 let mut current = self.root.clone();
172 for component in relative.components() {
173 let Component::Normal(part) = component else {
174 return Err(Error::Parse(format!(
175 "invalid resolved store path: {}",
176 self.relative_path(path).display()
177 )));
178 };
179 current.push(part);
180
181 match fs::symlink_metadata(¤t) {
182 Ok(metadata) if metadata.file_type().is_symlink() => {
183 return Err(Error::UnsafePath(self.relative_path(¤t)));
184 }
185 Ok(_) => {}
186 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
187 Err(error) => return Err(Error::Io(error)),
188 }
189 }
190
191 Ok(())
192 }
193
194 fn metadata_if_exists(&self, path: &Path) -> Result<Option<fs::Metadata>, Error> {
195 match fs::symlink_metadata(path) {
196 Ok(metadata) => {
197 if metadata.file_type().is_symlink() {
198 Err(Error::UnsafePath(self.relative_path(path)))
199 } else {
200 Ok(Some(metadata))
201 }
202 }
203 Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
204 Err(error) => Err(Error::Io(error)),
205 }
206 }
207
208 fn read_file_prefix(&self, path: &Path, max_bytes: usize) -> Result<Vec<u8>, Error> {
209 let mut file = fs::File::open(path)?;
210 let mut buffer = vec![0; max_bytes];
211 let bytes_read = std::io::Read::read(&mut file, &mut buffer)?;
212 buffer.truncate(bytes_read);
213 Ok(buffer)
214 }
215
216 fn cleanup_empty_parents(&self, mut current: Option<&Path>) -> Result<(), Error> {
217 while let Some(path) = current {
218 if path == self.root {
219 break;
220 }
221 if !path.exists() || !path.is_dir() || fs::read_dir(path)?.next().is_some() {
222 break;
223 }
224
225 fs::remove_dir(path)?;
226 current = path.parent();
227 }
228
229 Ok(())
230 }
231
232 fn build_tree_node(path: &Path, name: String) -> Result<TreeNode, Error> {
233 if !path.exists() {
234 return Ok(TreeNode {
235 name,
236 is_dir: true,
237 children: vec![],
238 });
239 }
240
241 let metadata = fs::metadata(path)?;
242 if !metadata.is_dir() {
243 return Ok(TreeNode {
244 name,
245 is_dir: false,
246 children: vec![],
247 });
248 }
249
250 let mut children = Vec::new();
251 for child in read_sorted_children(path, None)? {
252 children.push(Self::build_tree_node(&child.path, child.name)?);
253 }
254
255 Ok(TreeNode {
256 name,
257 is_dir: true,
258 children,
259 })
260 }
261}
262
263impl StorageBackend for LocalFsBackend {
264 fn create(&self, path: &str, content: &[u8]) -> Result<(), Error> {
265 self.ensure_store()?;
266 let target = self.resolve_path(path)?;
267 self.ensure_no_symlinks_below_root(&target)?;
268 if self.metadata_if_exists(&target)?.is_some() {
269 return Err(Error::AlreadyExists(self.relative_path(&target)));
270 }
271
272 if let Some(parent) = target.parent() {
273 fs::create_dir_all(parent)?;
274 }
275 fs::write(target, content)?;
276 Ok(())
277 }
278
279 fn overwrite(&self, path: &str, content: &[u8]) -> Result<(), Error> {
280 self.ensure_store()?;
281 let target = self.resolve_path(path)?;
282 self.ensure_no_symlinks_below_root(&target)?;
283 let _ = self.metadata_if_exists(&target)?;
284 if let Some(parent) = target.parent() {
285 fs::create_dir_all(parent)?;
286 }
287 fs::write(target, content)?;
288 Ok(())
289 }
290
291 fn read(&self, path: &str) -> Result<Vec<u8>, Error> {
292 let target = self.resolve_path(path)?;
293 self.ensure_no_symlinks_below_root(&target)?;
294 if self.metadata_if_exists(&target)?.is_none() {
295 return Err(Error::NotFound(self.relative_path(&target)));
296 }
297 Ok(fs::read(target)?)
298 }
299
300 fn read_prefix(&self, path: &str, max_bytes: usize) -> Result<Vec<u8>, Error> {
301 let target = self.resolve_path(path)?;
302 self.ensure_no_symlinks_below_root(&target)?;
303 if self.metadata_if_exists(&target)?.is_none() {
304 return Err(Error::NotFound(self.relative_path(&target)));
305 }
306 self.read_file_prefix(&target, max_bytes)
307 }
308
309 fn remove(&self, path: &str) -> Result<(), Error> {
310 let target = self.resolve_path(path)?;
311 self.ensure_no_symlinks_below_root(&target)?;
312 let metadata = self
313 .metadata_if_exists(&target)?
314 .ok_or_else(|| Error::NotFound(self.relative_path(&target)))?;
315
316 let parent = target.parent().map(Path::to_path_buf);
317 if metadata.is_dir() {
318 fs::remove_dir_all(&target)?;
319 } else {
320 fs::remove_file(&target)?;
321 }
322
323 self.cleanup_empty_parents(parent.as_deref())
324 }
325
326 fn list(&self, path: &str) -> Result<Vec<Entry>, Error> {
327 let target = self.resolve_path(path)?;
328 self.ensure_no_symlinks_below_root(&target)?;
329
330 let Some(metadata) = self.metadata_if_exists(&target)? else {
331 return if path.is_empty() {
332 Ok(vec![])
333 } else {
334 Err(Error::NotFound(self.relative_path(&target)))
335 };
336 };
337 if !metadata.is_dir() {
338 return Err(Error::NotADirectory(self.relative_path(&target)));
339 }
340
341 read_sorted_children(&target, Some(&self.root)).map(|children| {
342 children
343 .into_iter()
344 .map(|child| Entry {
345 name: child.name,
346 is_dir: child.is_dir,
347 })
348 .collect()
349 })
350 }
351
352 fn tree(&self, prefix: &str) -> Result<TreeNode, Error> {
353 let trimmed = prefix.trim_matches('/');
354 let target = self.resolve_path(trimmed)?;
355 self.ensure_no_symlinks_below_root(&target)?;
356 let name = Path::new(trimmed)
357 .file_name()
358 .map(|name| name.to_string_lossy().to_string())
359 .unwrap_or_else(|| ".".to_string());
360 Self::build_tree_node(&target, name)
361 }
362
363 fn walk(&self, prefix: &str) -> Result<Vec<String>, Error> {
364 let target = self.resolve_path(prefix)?;
365 self.ensure_no_symlinks_below_root(&target)?;
366
367 let Some(metadata) = self.metadata_if_exists(&target)? else {
368 return Ok(vec![]);
369 };
370 if is_hidden_path(&target, &self.root) {
371 return Ok(vec![]);
372 }
373
374 let mut walked = Vec::new();
375 for entry in WalkDir::new(&target)
376 .into_iter()
377 .filter_entry(|entry| !is_hidden_path(entry.path(), &self.root))
378 {
379 let entry = entry.map_err(|error| Error::Io(std::io::Error::other(error)))?;
380 if entry.path() != target && entry.file_type().is_symlink() {
381 return Err(Error::UnsafePath(self.relative_path(entry.path())));
382 }
383 if metadata.is_file() || entry.file_type().is_file() {
384 walked.push(
385 entry
386 .path()
387 .strip_prefix(&self.root)
388 .map_err(|_| {
389 Error::Parse(format!(
390 "path is outside the configured store root: {}",
391 self.relative_path(entry.path()).display()
392 ))
393 })?
394 .to_string_lossy()
395 .to_string(),
396 );
397 }
398 }
399
400 walked.sort();
401 Ok(walked)
402 }
403
404 fn exists(&self, path: &str) -> Result<bool, Error> {
405 let target = self.resolve_path(path)?;
406 self.ensure_no_symlinks_below_root(&target)?;
407 Ok(self.metadata_if_exists(&target)?.is_some())
408 }
409}
410
411fn default_store_root(home: &Path) -> PathBuf {
412 home.join(".stakpak/knowledge")
413}
414
415struct ChildEntry {
416 path: PathBuf,
417 name: String,
418 is_dir: bool,
419}
420
421fn read_sorted_children(path: &Path, root: Option<&Path>) -> Result<Vec<ChildEntry>, Error> {
422 let mut children = Vec::new();
423 for entry in fs::read_dir(path)? {
424 let entry = entry?;
425 let name = entry.file_name().to_string_lossy().to_string();
426 if name.starts_with('.') {
427 continue;
428 }
429
430 let file_type = entry.file_type()?;
431 let child_path = entry.path();
432 if file_type.is_symlink() {
433 let display_path = root
434 .and_then(|r| child_path.strip_prefix(r).ok().map(PathBuf::from))
435 .unwrap_or_else(|| child_path.clone());
436 return Err(Error::UnsafePath(display_path));
437 }
438
439 children.push(ChildEntry {
440 path: child_path,
441 name,
442 is_dir: file_type.is_dir(),
443 });
444 }
445
446 children.sort_by(compare_entries);
447 Ok(children)
448}
449
450fn compare_entries(left: &ChildEntry, right: &ChildEntry) -> Ordering {
451 match right.is_dir.cmp(&left.is_dir) {
452 Ordering::Equal => left.name.cmp(&right.name),
453 other => other,
454 }
455}
456
457fn is_hidden_path(path: &Path, root: &Path) -> bool {
458 path.strip_prefix(root)
459 .map(|relative| {
460 relative
461 .components()
462 .any(|component| matches!(component, Component::Normal(part) if part.to_string_lossy().starts_with('.')))
463 })
464 .unwrap_or(false)
465}
466
467#[derive(Clone, Debug)]
473pub struct RemoteBackend {
474 client: StakpakApiClient,
475}
476
477impl RemoteBackend {
478 pub fn new(config: &StakpakApiConfig) -> Result<Self, Error> {
479 let client = StakpakApiClient::new(config).map_err(Error::Parse)?;
480 Ok(Self { client })
481 }
482
483 pub fn with_client(client: StakpakApiClient) -> Self {
484 Self { client }
485 }
486}
487
488impl StorageBackend for RemoteBackend {
489 fn create(&self, path: &str, content: &[u8]) -> Result<(), Error> {
490 let handle = tokio::runtime::Handle::try_current().map_err(|_| {
491 Error::Parse("remote backend requires a running tokio runtime".to_string())
492 })?;
493 tokio::task::block_in_place(|| {
494 handle.block_on(async { self.client.create_knowledge_file(path, content).await })
495 })
496 .map(|_| ())
497 .map_err(|e| map_knowledge_err(path, e))
498 }
499
500 fn overwrite(&self, path: &str, content: &[u8]) -> Result<(), Error> {
501 tokio::task::block_in_place(|| {
502 tokio::runtime::Handle::current()
503 .block_on(async { self.client.overwrite_knowledge_file(path, content).await })
504 })
505 .map(|_| ())
506 .map_err(|e| map_knowledge_err(path, e))
507 }
508
509 fn read(&self, path: &str) -> Result<Vec<u8>, Error> {
510 tokio::task::block_in_place(|| {
511 tokio::runtime::Handle::current()
512 .block_on(async { self.client.read_knowledge_file(path).await })
513 })
514 .map_err(|e| map_knowledge_err(path, e))
515 }
516
517 fn read_prefix(&self, path: &str, max_bytes: usize) -> Result<Vec<u8>, Error> {
518 tokio::task::block_in_place(|| {
522 tokio::runtime::Handle::current()
523 .block_on(async { self.client.peek_knowledge_file(path, max_bytes).await })
524 })
525 .map_err(|e| map_knowledge_err(path, e))
526 }
527
528 fn remove(&self, path: &str) -> Result<(), Error> {
529 tokio::task::block_in_place(|| {
530 tokio::runtime::Handle::current()
531 .block_on(async { self.client.delete_knowledge_file(path).await })
532 })
533 .map_err(|e| map_knowledge_err(path, e))
534 }
535
536 fn list(&self, path: &str) -> Result<Vec<Entry>, Error> {
537 let query = ListKnowledgeFilesQuery {
538 path: if path.is_empty() {
539 None
540 } else {
541 Some(path.to_string())
542 },
543 glob: None,
544 };
545
546 let response = tokio::task::block_in_place(|| {
547 tokio::runtime::Handle::current()
548 .block_on(async { self.client.list_knowledge_files(&query).await })
549 })
550 .map_err(|e| map_knowledge_err(path, e))?;
551
552 if path.is_empty() && response.files.is_empty() {
554 return Ok(vec![]);
555 }
556
557 if response.files.is_empty() && !path.is_empty() {
559 return Err(Error::NotFound(PathBuf::from(path)));
560 }
561
562 if response.files.len() == 1 && response.files[0].path == path {
565 return Err(Error::NotADirectory(PathBuf::from(path)));
566 }
567
568 let prefix = Path::new(path);
571 let mut entries: std::collections::HashMap<String, bool> = std::collections::HashMap::new();
572 for file in response.files {
573 let file_path = Path::new(&file.path);
574 if let Ok(relative) = file_path.strip_prefix(prefix) {
575 let mut components = relative.components();
576 if let Some(Component::Normal(name)) = components.next() {
577 let name = name.to_string_lossy().to_string();
578 let is_dir = components.next().is_some();
579 entries
582 .entry(name)
583 .and_modify(|existing| *existing = *existing || is_dir)
584 .or_insert(is_dir);
585 }
586 }
587 }
588
589 let mut result: Vec<Entry> = entries
590 .into_iter()
591 .map(|(name, is_dir)| Entry { name, is_dir })
592 .collect();
593 result.sort_by(|a, b| match b.is_dir.cmp(&a.is_dir) {
594 std::cmp::Ordering::Equal => a.name.cmp(&b.name),
595 other => other,
596 });
597 Ok(result)
598 }
599
600 fn tree(&self, prefix: &str) -> Result<TreeNode, Error> {
601 let query = ListKnowledgeFilesQuery {
602 path: if prefix.is_empty() {
603 None
604 } else {
605 Some(prefix.to_string())
606 },
607 glob: None,
608 };
609
610 let response = tokio::task::block_in_place(|| {
611 tokio::runtime::Handle::current()
612 .block_on(async { self.client.list_knowledge_files(&query).await })
613 })
614 .map_err(|e| map_knowledge_err(prefix, e))?;
615
616 let name = if prefix.is_empty() {
618 ".".to_string()
619 } else {
620 Path::new(prefix)
621 .file_name()
622 .map(|n| n.to_string_lossy().to_string())
623 .unwrap_or_else(|| prefix.to_string())
624 };
625
626 let mut root = TreeNode {
627 name,
628 is_dir: true,
629 children: vec![],
630 };
631
632 for file in response.files {
633 self.add_file_to_tree(&mut root, &file.path, prefix);
634 }
635
636 Ok(root)
637 }
638
639 fn walk(&self, prefix: &str) -> Result<Vec<String>, Error> {
640 let query = ListKnowledgeFilesQuery {
641 path: if prefix.is_empty() {
642 None
643 } else {
644 Some(prefix.to_string())
645 },
646 glob: None,
647 };
648
649 let response = tokio::task::block_in_place(|| {
650 tokio::runtime::Handle::current()
651 .block_on(async { self.client.list_knowledge_files(&query).await })
652 })
653 .map_err(|e| map_knowledge_err(prefix, e))?;
654
655 let mut paths: Vec<String> = response.files.into_iter().map(|f| f.path).collect();
656 paths.sort();
657 Ok(paths)
658 }
659
660 fn exists(&self, path: &str) -> Result<bool, Error> {
661 tokio::task::block_in_place(|| {
663 tokio::runtime::Handle::current()
664 .block_on(async { self.client.knowledge_file_exists(path).await })
665 })
666 .map_err(|e| map_knowledge_err(path, e))
667 }
668}
669
670impl RemoteBackend {
671 fn add_file_to_tree(&self, root: &mut TreeNode, file_path: &str, prefix: &str) {
672 let path = Path::new(file_path);
673 let prefix_path = if prefix.is_empty() {
674 Path::new("")
675 } else {
676 Path::new(prefix)
677 };
678
679 if let Ok(relative) = path.strip_prefix(prefix_path) {
680 let components: Vec<_> = relative.components().collect();
681 self.insert_components(root, &components, 0);
682 }
683 }
684
685 fn insert_components(
686 &self,
687 node: &mut TreeNode,
688 components: &[std::path::Component],
689 index: usize,
690 ) {
691 if index >= components.len() {
692 return;
693 }
694
695 if let Component::Normal(name) = components[index] {
696 let name = name.to_string_lossy().to_string();
697 let is_last = index == components.len() - 1;
698
699 let child_index = node.children.iter().position(|c| c.name == name);
701
702 if let Some(idx) = child_index {
703 if !is_last {
704 self.insert_components(&mut node.children[idx], components, index + 1);
705 }
706 } else {
707 let new_child = if is_last {
708 TreeNode {
709 name,
710 is_dir: false,
711 children: vec![],
712 }
713 } else {
714 let mut new_node = TreeNode {
715 name: name.clone(),
716 is_dir: true,
717 children: vec![],
718 };
719 self.insert_components(&mut new_node, components, index + 1);
720 new_node
721 };
722 node.children.push(new_child);
723 node.children.sort_by(|a, b| match b.is_dir.cmp(&a.is_dir) {
724 std::cmp::Ordering::Equal => a.name.cmp(&b.name),
725 other => other,
726 });
727 }
728 }
729 }
730}
731
732#[cfg(test)]
733mod tests {
734 use super::{Entry, LocalFsBackend, StorageBackend, TreeNode};
735
736 #[cfg(unix)]
737 use std::os::unix::fs::PermissionsExt;
738 #[cfg(unix)]
739 use std::os::unix::fs::symlink;
740
741 fn backend() -> (tempfile::TempDir, LocalFsBackend) {
742 let temp_dir = tempfile::TempDir::new().expect("temp dir");
743 let backend = LocalFsBackend::with_root(temp_dir.path().join("store"));
744 (temp_dir, backend)
745 }
746
747 #[test]
748 fn create_writes_new_file() {
749 let (_temp_dir, backend) = backend();
750
751 backend
752 .create("knowledge/rate-limits.md", b"1000/min")
753 .expect("create file");
754
755 let content = std::fs::read_to_string(backend.root().join("knowledge/rate-limits.md"))
756 .expect("read file from disk");
757 assert_eq!(content, "1000/min");
758 }
759
760 #[test]
761 fn create_fails_when_file_already_exists() {
762 let (_temp_dir, backend) = backend();
763 backend
764 .create("knowledge/rate-limits.md", b"first")
765 .expect("create initial file");
766
767 let error = backend
768 .create("knowledge/rate-limits.md", b"second")
769 .expect_err("duplicate create should fail");
770
771 assert!(matches!(error, crate::Error::AlreadyExists(_)));
772 }
773
774 #[test]
775 fn overwrite_replaces_existing_content() {
776 let (_temp_dir, backend) = backend();
777 backend
778 .create("summaries/auth.md", b"old")
779 .expect("create initial summary");
780
781 backend
782 .overwrite("summaries/auth.md", b"new")
783 .expect("overwrite file");
784
785 let content = backend
786 .read("summaries/auth.md")
787 .expect("read overwritten file");
788 assert_eq!(content, b"new");
789 }
790
791 #[test]
792 fn read_returns_not_found_for_missing_file() {
793 let (_temp_dir, backend) = backend();
794
795 let error = backend
796 .read("knowledge/missing.md")
797 .expect_err("missing file should fail");
798
799 assert!(matches!(error, crate::Error::NotFound(_)));
800 }
801
802 #[test]
803 fn remove_deletes_file() {
804 let (_temp_dir, backend) = backend();
805 backend
806 .create("knowledge/old.md", b"old")
807 .expect("create file");
808
809 backend.remove("knowledge/old.md").expect("remove file");
810
811 assert!(!backend.root().join("knowledge/old.md").exists());
812 }
813
814 #[test]
815 fn remove_cleans_empty_parent_directories() {
816 let (_temp_dir, backend) = backend();
817 backend
818 .create("deep/nested/only-file.md", b"old")
819 .expect("create nested file");
820
821 backend
822 .remove("deep/nested/only-file.md")
823 .expect("remove nested file");
824
825 assert!(!backend.root().join("deep/nested").exists());
826 assert!(!backend.root().join("deep").exists());
827 assert!(backend.root().exists());
828 }
829
830 #[test]
831 fn list_returns_sorted_entries_without_dotfiles() {
832 let (_temp_dir, backend) = backend();
833 std::fs::create_dir_all(backend.root().join("knowledge/subdir")).expect("create subdir");
834 std::fs::write(backend.root().join("knowledge/z-last.md"), "z").expect("write z file");
835 std::fs::write(backend.root().join("knowledge/a-first.md"), "a").expect("write a file");
836 std::fs::write(backend.root().join("knowledge/.hidden.md"), "h")
837 .expect("write hidden file");
838
839 let entries = backend.list("knowledge").expect("list directory");
840
841 assert_eq!(
842 entries,
843 vec![
844 Entry {
845 name: "subdir".to_string(),
846 is_dir: true,
847 },
848 Entry {
849 name: "a-first.md".to_string(),
850 is_dir: false,
851 },
852 Entry {
853 name: "z-last.md".to_string(),
854 is_dir: false,
855 },
856 ]
857 );
858 }
859
860 #[test]
861 fn tree_builds_recursive_sorted_structure_without_dotfiles() {
862 let (_temp_dir, backend) = backend();
863 backend
864 .create("knowledge/rate-limits.md", b"1000/min")
865 .expect("create knowledge file");
866 backend
867 .create("entities/auth-service.md", b"OAuth")
868 .expect("create entity file");
869 std::fs::write(backend.root().join(".hidden.md"), "hidden").expect("write hidden file");
870
871 let tree = backend.tree("").expect("build tree");
872
873 assert_eq!(
874 tree,
875 TreeNode {
876 name: ".".to_string(),
877 is_dir: true,
878 children: vec![
879 TreeNode {
880 name: "entities".to_string(),
881 is_dir: true,
882 children: vec![TreeNode {
883 name: "auth-service.md".to_string(),
884 is_dir: false,
885 children: vec![],
886 }],
887 },
888 TreeNode {
889 name: "knowledge".to_string(),
890 is_dir: true,
891 children: vec![TreeNode {
892 name: "rate-limits.md".to_string(),
893 is_dir: false,
894 children: vec![],
895 }],
896 },
897 ],
898 }
899 );
900 }
901
902 #[test]
903 fn tree_returns_scoped_subtree() {
904 let (_temp_dir, backend) = backend();
905 backend
906 .create("services/auth/flows.md", b"Auth flow\n")
907 .expect("create auth file");
908 backend
909 .create("services/rate-limits.md", b"Rate limit\n")
910 .expect("create rate file");
911 backend
912 .create("notes/todo.md", b"Todo\n")
913 .expect("create notes file");
914
915 assert_eq!(
916 backend.tree("services").expect("scoped tree"),
917 TreeNode {
918 name: "services".to_string(),
919 is_dir: true,
920 children: vec![
921 TreeNode {
922 name: "auth".to_string(),
923 is_dir: true,
924 children: vec![TreeNode {
925 name: "flows.md".to_string(),
926 is_dir: false,
927 children: vec![],
928 }],
929 },
930 TreeNode {
931 name: "rate-limits.md".to_string(),
932 is_dir: false,
933 children: vec![],
934 },
935 ],
936 }
937 );
938 }
939
940 #[test]
941 fn tree_returns_empty_directory_for_missing_prefix() {
942 let (_temp_dir, backend) = backend();
943
944 assert_eq!(
945 backend.tree("missing").expect("missing tree"),
946 TreeNode {
947 name: "missing".to_string(),
948 is_dir: true,
949 children: vec![],
950 }
951 );
952 }
953
954 #[test]
955 fn tree_node_print_renders_connectors() {
956 let tree = TreeNode {
957 name: ".".to_string(),
958 is_dir: true,
959 children: vec![
960 TreeNode {
961 name: "knowledge".to_string(),
962 is_dir: true,
963 children: vec![TreeNode {
964 name: "rate-limits.md".to_string(),
965 is_dir: false,
966 children: vec![],
967 }],
968 },
969 TreeNode {
970 name: "notes.md".to_string(),
971 is_dir: false,
972 children: vec![],
973 },
974 ],
975 };
976
977 assert_eq!(
978 tree.print(),
979 ".\n├── knowledge\n│ └── rate-limits.md\n└── notes.md"
980 );
981 }
982
983 #[test]
984 fn exists_reports_whether_path_exists() {
985 let (_temp_dir, backend) = backend();
986 backend
987 .create("knowledge/rate-limits.md", b"1000/min")
988 .expect("create file");
989
990 assert!(
991 backend
992 .exists("knowledge/rate-limits.md")
993 .expect("existing path check")
994 );
995 assert!(
996 !backend
997 .exists("knowledge/missing.md")
998 .expect("missing path check")
999 );
1000 }
1001
1002 #[test]
1003 fn file_count_counts_non_dotfiles_only() {
1004 let (_temp_dir, backend) = backend();
1005 backend
1006 .create("knowledge/rate-limits.md", b"1000/min")
1007 .expect("create knowledge file");
1008 backend
1009 .create("entities/auth-service.md", b"OAuth")
1010 .expect("create entity file");
1011 std::fs::write(backend.root().join(".hidden.md"), "hidden").expect("write hidden file");
1012
1013 assert_eq!(backend.file_count().expect("count files"), 2);
1014 }
1015
1016 #[test]
1017 fn new_defaults_to_stakpak_knowledge_store() {
1018 let home = std::path::Path::new("/tmp/test-home");
1019
1020 assert_eq!(
1021 super::default_store_root(home),
1022 home.join(".stakpak/knowledge")
1023 );
1024 }
1025
1026 #[test]
1027 fn list_root_returns_empty_when_store_does_not_exist() {
1028 let temp_dir = tempfile::TempDir::new().expect("temp dir");
1029 let backend = LocalFsBackend::with_root(temp_dir.path().join("missing-store"));
1030
1031 let entries = backend.list("").expect("list missing root");
1032
1033 assert!(entries.is_empty());
1034 }
1035
1036 #[cfg(unix)]
1037 #[test]
1038 fn read_rejects_symlinked_file_inside_store() {
1039 let (_temp_dir, backend) = backend();
1040 let outside = tempfile::NamedTempFile::new().expect("outside temp file");
1041 std::fs::write(outside.path(), "secret").expect("write outside file");
1042 std::fs::create_dir_all(backend.root()).expect("create store root");
1043 symlink(outside.path(), backend.root().join("leak.md")).expect("create symlink");
1044
1045 let error = backend
1046 .read("leak.md")
1047 .expect_err("symlink read should fail");
1048
1049 assert!(matches!(error, crate::Error::UnsafePath(_)));
1050 }
1051
1052 #[cfg(unix)]
1053 #[test]
1054 fn create_rejects_symlinked_parent_directory_inside_store() {
1055 let (_temp_dir, backend) = backend();
1056 let outside = tempfile::TempDir::new().expect("outside temp dir");
1057 std::fs::create_dir_all(backend.root()).expect("create store root");
1058 symlink(outside.path(), backend.root().join("knowledge")).expect("create symlink dir");
1059
1060 let error = backend
1061 .create("knowledge/pwned.md", b"hello")
1062 .expect_err("symlink parent should fail");
1063
1064 assert!(matches!(error, crate::Error::UnsafePath(_)));
1065 assert!(!outside.path().join("pwned.md").exists());
1066 }
1067
1068 #[test]
1069 fn create_rejects_parent_directory_traversal() {
1070 let (temp_dir, backend) = backend();
1071 let outside = temp_dir.path().join("outside.md");
1072
1073 let error = backend
1074 .create("../outside.md", b"pwned")
1075 .expect_err("parent traversal should fail");
1076
1077 assert!(matches!(error, crate::Error::Parse(_)));
1078 assert!(!outside.exists());
1079 }
1080
1081 #[test]
1082 fn read_rejects_parent_directory_traversal() {
1083 let (temp_dir, backend) = backend();
1084 let outside = temp_dir.path().join("outside.md");
1085 std::fs::write(&outside, "secret").expect("write outside file");
1086
1087 let error = backend
1088 .read("../outside.md")
1089 .expect_err("parent traversal read should fail");
1090
1091 assert!(matches!(error, crate::Error::Parse(_)));
1092 }
1093
1094 #[test]
1095 fn list_rejects_absolute_path_traversal() {
1096 let (_temp_dir, backend) = backend();
1097 let absolute = backend.root().join("knowledge");
1098 let absolute = absolute.to_string_lossy().to_string();
1099
1100 let error = backend
1101 .list(&absolute)
1102 .expect_err("absolute path traversal should fail");
1103
1104 assert!(matches!(error, crate::Error::Parse(_)));
1105 }
1106
1107 #[cfg(unix)]
1108 #[test]
1109 fn file_count_returns_error_for_unreadable_directory() {
1110 let (_temp_dir, backend) = backend();
1111 backend
1112 .create("knowledge/readable.md", b"ok")
1113 .expect("create readable file");
1114 std::fs::create_dir_all(backend.root().join("knowledge/private"))
1115 .expect("create private dir");
1116
1117 let private_dir = backend.root().join("knowledge/private");
1118 let original_permissions = std::fs::metadata(&private_dir)
1119 .expect("read metadata")
1120 .permissions();
1121 std::fs::set_permissions(&private_dir, std::fs::Permissions::from_mode(0o0))
1122 .expect("remove permissions");
1123
1124 let result = backend.file_count();
1125
1126 std::fs::set_permissions(&private_dir, original_permissions).expect("restore permissions");
1127 assert!(
1128 result.is_err(),
1129 "expected unreadable directory to return an error"
1130 );
1131 }
1132
1133 #[test]
1134 fn walk_returns_sorted_relative_files_from_store_root() {
1135 let (_temp_dir, backend) = backend();
1136 backend
1137 .create("services/auth/flows.md", b"auth")
1138 .expect("create nested file");
1139 backend
1140 .create("notes/todo.md", b"todo")
1141 .expect("create top-level file");
1142 std::fs::create_dir_all(backend.root().join("services/.private"))
1143 .expect("create hidden dir");
1144 std::fs::write(backend.root().join(".hidden.md"), "hidden")
1145 .expect("write hidden root file");
1146 std::fs::write(backend.root().join("services/.secret.md"), "hidden")
1147 .expect("write hidden nested file");
1148 std::fs::write(
1149 backend.root().join("services/.private/ignored.md"),
1150 "hidden",
1151 )
1152 .expect("write hidden-dir file");
1153
1154 let walked = backend.walk("").expect("walk store root");
1155
1156 assert_eq!(
1157 walked,
1158 vec![
1159 "notes/todo.md".to_string(),
1160 "services/auth/flows.md".to_string(),
1161 ]
1162 );
1163 }
1164
1165 #[test]
1166 fn walk_scopes_to_prefix() {
1167 let (_temp_dir, backend) = backend();
1168 backend
1169 .create("services/auth/flows.md", b"auth")
1170 .expect("create auth file");
1171 backend
1172 .create("services/billing/limits.md", b"limits")
1173 .expect("create billing file");
1174 backend
1175 .create("notes/todo.md", b"todo")
1176 .expect("create notes file");
1177
1178 let walked = backend.walk("services/auth").expect("walk subtree");
1179
1180 assert_eq!(walked, vec!["services/auth/flows.md".to_string()]);
1181 }
1182
1183 #[test]
1184 fn walk_returns_empty_for_missing_prefix() {
1185 let (_temp_dir, backend) = backend();
1186 backend
1187 .create("notes/todo.md", b"todo")
1188 .expect("create notes file");
1189
1190 let walked = backend.walk("missing").expect("walk missing prefix");
1191
1192 assert!(walked.is_empty());
1193 }
1194}