nibb_core/snippets/
repo.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use slug::slugify;
4use crate::config::config::Config;
5use crate::get_nibb_dir;
6use crate::git::git_integration::GitRepo;
7use crate::result::{NibbError, NibbResult};
8use crate::snippets::snippet::{Meta, Snippet};
9/// Defines the interface for a snippet repository backend.
10///
11/// Allows loading, saving, and deleting individual or multiple [`Snippet`]s
12/// via a consistent API, independent of the concrete storage type (e.g. FS, DB, etc.).
13pub trait SnippetRepository {
14    /// Load all available snippets from the repository.
15    fn load_all(&self) -> NibbResult<Vec<Snippet>>;
16    /// Load a single snippet by its slugified name.
17    fn load(&self, slug: &str) -> NibbResult<Snippet>;
18    /// Save or update a single snippet.
19    fn save(&self, snippet: &Snippet) -> NibbResult<()>;
20    /// Save or update a batch of snippets.
21    fn save_all(&self, snippets: &[Snippet]) -> NibbResult<()>;
22    /// Delete a snippet by its slug.
23    fn delete(&self, slug: &str) -> NibbResult<()>;
24}
25/// Filesystem-backed implementation of [`SnippetRepository`].
26///
27/// Snippets are stored under a root `base_dir`, each in a dedicated subdirectory named by slug.
28/// Each snippet directory contains:
29/// - `meta.toml`: metadata (name, tags, language, etc.)
30/// - `content.<ext>`: raw snippet content file
31/// Additionally, `base_dir` contains:
32/// - `snippets/`: all snippet folders
33/// - `history/`: reserved for future versioning/history features
34/// - `config.toml`: configuration file (created if missing)
35pub struct FSRepo {
36    /// Root directory containing all snippet data.
37    pub base_dir: PathBuf,
38    pub config: Config,
39    pub git_repo: GitRepo,
40}
41
42impl FSRepo {
43    /// Creates a new [`FSRepo`] and ensures the necessary folder structure exists.
44    ///
45    /// Will create `snippets/`, `history/`, and `config.toml` if missing.
46    pub fn new<P: AsRef<Path>>(path: P) -> NibbResult<Self> {
47        let config = Config::load(&path.as_ref().join("config.toml"))?;
48        let git_repo = GitRepo::init_or_open(get_nibb_dir()?)?;
49        let repo = Self {
50            git_repo,
51            config,
52            base_dir: path.as_ref().to_path_buf(),
53        };
54        repo.ensure_structure()?;
55        Ok(repo)
56    }
57    /// Returns the path to a snippet's directory based on its slug.
58    pub fn snippet_path(&self, slug: &str) -> PathBuf {
59        self.snippets_dir().join(slug)
60    }
61    fn snippets_dir(&self) -> PathBuf {
62        self.base_dir.join("snippets")
63    }
64    fn history_dir(&self) -> PathBuf {
65        self.base_dir.join("history")
66    }
67    fn config_path(&self) -> PathBuf {
68        self.base_dir.join("config.toml")
69    }
70    fn ensure_structure(&self) -> NibbResult<()> {
71        fs::create_dir_all(&self.base_dir)?;
72        fs::create_dir_all(self.snippets_dir())?;
73        fs::create_dir_all(self.history_dir())?;
74        let config_path = self.config_path();
75        if !config_path.exists() {
76            fs::File::create(config_path)?;
77        }
78        Ok(())
79    }
80    fn auto_commit(&self, snippet: &Snippet) -> NibbResult<()> {
81        if !self.config.git.enabled || !self.config.git.auto_commit {
82            return Ok(());
83        }
84        self.git_repo.add_and_commit(snippet, &self.config)?;
85        self.auto_push()?;
86        Ok(())
87    }
88    fn auto_push(&self) -> NibbResult<()> {
89        if !self.config.git.enabled || !self.config.git.push_on_commit {
90            return Ok(());
91        }
92        match &self.config.git.remote {
93            Some(remote) => {
94                Ok(self.git_repo.push(remote, &self.config.git.branch)?)
95            },
96            None => Ok(())
97        }
98    }
99    fn get_content_path(&self, slug: &str, extension: &str) -> PathBuf {
100        let snippet_path = self.snippet_path(slug);
101        snippet_path.join(format!("content.{}", extension))
102    }
103    fn get_meta_path(&self, slug: &str) -> PathBuf {
104        let snippet_path = self.snippet_path(slug);
105        snippet_path.join("meta.toml")
106    }
107}
108
109impl SnippetRepository for FSRepo {
110    /// Loads all snippets by iterating through the `snippets/` directory
111    /// and deserializing each snippet from `meta.toml` and its content file.
112    fn load_all(&self) -> NibbResult<Vec<Snippet>> {
113        let entries = std::fs::read_dir(self.snippets_dir())
114            .map_err(|e| NibbError::NotFound(format!("{}:{:?}", e.to_string(), self.snippets_dir())))?;
115        let mut snippets = Vec::new();
116        for entry in entries {
117            let entry = entry.map_err(|e| NibbError::NotFound(format!("{}:{:?}", e.to_string(), self.snippets_dir())))?;
118            let slug = slugify(entry.file_name().to_str().unwrap());
119            snippets.push(self.load(&slug)?);
120        }
121        Ok(snippets)
122    }
123    /// Loads a specific snippet by slug.
124    ///
125    /// Reads metadata from `meta.toml` and content from `content.<ext>`.
126    fn load(&self, slug: &str) -> NibbResult<Snippet> {
127        let slug = slugify(slug); // just to be sure
128        let meta_path = self.get_meta_path(&slug);
129        let meta_str = std::fs::read_to_string(&meta_path)
130            .map_err(|e| NibbError::NotFound(format!("{}:{:?}", e.to_string(), &meta_path)))?;
131        let meta: Meta = toml::from_str(&meta_str)
132            .map_err(|e| NibbError::NotFound(format!("{}:{:?}", e.to_string(), &meta_path)))?;
133
134        let content_path = self.get_content_path(&slug, &meta.get_content_extension());
135        let content = std::fs::read_to_string(&content_path)
136            .map_err(|e| NibbError::NotFound(format!("{}:{:?}", e.to_string(), &content_path)))?;
137        Ok(Snippet {
138            meta,
139            content,
140        })
141    }
142    /// Saves a single snippet to disk.
143    ///
144    /// Creates the snippet folder and both metadata/content files if they don't exist.
145    fn save(&self, snippet: &Snippet) -> NibbResult<()> {
146        let slug = snippet.meta.get_slug();
147        let snippet_path = self.snippet_path(&slug);
148
149        if !snippet_path.exists() {
150            fs::create_dir_all(&snippet_path)
151                .map_err(|e| NibbError::NotFound(format!("{}:{:?}", e.to_string(), &snippet_path)))?;
152        }
153
154        let meta_path = self.get_meta_path(&slug);
155        fs::write(&meta_path, toml::to_string(&snippet.meta)?)
156            .map_err(|e| NibbError::NotFound(format!("{}:{:?}", e.to_string(), &meta_path)))?;
157
158        for entry in fs::read_dir(&snippet_path)? {
159            let entry = entry?;
160            let path = entry.path();
161
162            if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
163                if file_name.starts_with("content.") {
164                    fs::remove_file(&path)
165                        .map_err(|e| NibbError::NotFound(format!("Failed to remove old content file {}: {}", path.display(), e)))?;
166                }
167            }
168        }
169
170        let extension = snippet.meta.get_content_extension();
171        let content_path = self.get_content_path(&slug, &extension);
172
173        fs::write(&content_path, &snippet.content)
174            .map_err(|e| NibbError::NotFound(format!("{}:{:?}", e.to_string(), &content_path)))?;
175
176        // git actions (handles config)
177        self.auto_commit(snippet)?;
178        Ok(())
179    }
180    /// Saves a list of snippets.
181    ///
182    /// Calls [`save()`](Self::save) for each snippet.
183    fn save_all(&self, snippets: &[Snippet]) -> NibbResult<()> {
184        for snippet in snippets {
185            self.save(snippet)?;
186        }
187        Ok(())
188    }
189    /// Deletes the snippet directory and all its contents.
190    fn delete(&self, slug: &str) -> NibbResult<()> {
191        let slug = slugify(slug); // just to be sure
192        let snippet_path = self.snippet_path(&slug);
193        fs::remove_dir_all(snippet_path)?;
194        Ok(())
195    }
196}