Skip to main content

stakpak_ak/
store.rs

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
12/// Translate a typed knowledge-API error into the local [`Error`] enum.
13///
14/// `path` is captured so we can build a `PathBuf` for `NotFound`/`AlreadyExists`
15/// variants without paying the cost on the success path.
16fn 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    /// Returns a directory tree rooted at `prefix` (empty string for the store root).
32    /// The root node's name is the prefix's last component, or `.` for the store root.
33    /// Missing prefixes return an empty directory node.
34    fn tree(&self, prefix: &str) -> Result<TreeNode, Error>;
35    /// Returns sorted store-relative file paths under `prefix`, excluding dotfiles.
36    /// Missing prefixes return an empty result.
37    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    /// Return the store-relative version of an absolute path for use in error messages.
89    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(&current) {
182                Ok(metadata) if metadata.file_type().is_symlink() => {
183                    return Err(Error::UnsafePath(self.relative_path(&current)));
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// =============================================================================
468// Remote Backend
469// =============================================================================
470
471/// Remote storage backend that syncs to Stakpak cloud
472#[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        // Use the server's ?peek=true preview to avoid downloading the full
519        // body. If the server still returns more than `max_bytes` the client
520        // truncates locally.
521        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        // Empty path (root) is always a directory.
553        if path.is_empty() && response.files.is_empty() {
554            return Ok(vec![]);
555        }
556
557        // If no files returned for a non-root path, it doesn't exist.
558        if response.files.is_empty() && !path.is_empty() {
559            return Err(Error::NotFound(PathBuf::from(path)));
560        }
561
562        // Server returned exactly one file whose path equals the requested
563        // path — it's a file, not a directory.
564        if response.files.len() == 1 && response.files[0].path == path {
565            return Err(Error::NotADirectory(PathBuf::from(path)));
566        }
567
568        // Group by first path component beneath `path` and decide whether
569        // each entry is a directory (has further components).
570        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                    // Promote to directory if any path under this name has
580                    // further components.
581                    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        // Build tree from flat file list
617        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        // Use HEAD so we don't pay the cost of transferring the body.
662        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            // Find or create child
700            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}