Skip to main content

max / ripgrow

6.3 KB · 211 lines History Blame Raw
1 //! Profile file discovery on disk.
2 //!
3 //! One SQLite file per profile at
4 //! `~/Library/Application Support/ripgrow/profiles/<slug>.db`. This module
5 //! turns display names into filesystem-safe slugs, resolves the profile
6 //! directory, lists existing profiles, and opens (creating on demand) a
7 //! profile by name.
8
9 use std::fs;
10 use std::path::{Path, PathBuf};
11
12 use crate::db::Db;
13 use crate::values::LoadUnit;
14 use crate::error::Error;
15
16 /// Return the profiles directory, creating it if missing.
17 ///
18 /// macOS: `~/Library/Application Support/ripgrow/profiles/`. Other platforms
19 /// fall back to the OS "data dir" per the `dirs` crate. The whole tree is
20 /// created if any component is missing.
21 pub fn profiles_dir() -> Result<PathBuf, Error> {
22 let base = dirs::data_dir().ok_or_else(|| {
23 Error::Io(std::io::Error::other("could not resolve OS data dir"))
24 })?;
25 let dir = base.join("ripgrow").join("profiles");
26 fs::create_dir_all(&dir)?;
27 Ok(dir)
28 }
29
30 /// Turn a display name ("Jane Doe", "self") into a filesystem-safe slug.
31 ///
32 /// Rules: lowercase, ASCII letters/digits/hyphen only, whitespace and other
33 /// characters collapse into single hyphens, no leading/trailing hyphens.
34 /// Empty input becomes `"profile"`.
35 pub fn slugify(name: &str) -> String {
36 let mut out = String::with_capacity(name.len());
37 let mut prev_hyphen = true;
38 for c in name.chars() {
39 let ch = c.to_ascii_lowercase();
40 if ch.is_ascii_alphanumeric() {
41 out.push(ch);
42 prev_hyphen = false;
43 } else if !prev_hyphen {
44 out.push('-');
45 prev_hyphen = true;
46 }
47 }
48 while out.ends_with('-') {
49 out.pop();
50 }
51 if out.is_empty() {
52 "profile".to_string()
53 } else {
54 out
55 }
56 }
57
58 /// A profile file on disk.
59 #[derive(Debug, Clone, PartialEq, Eq)]
60 pub struct Profile {
61 /// The slug (filename stem). Not necessarily the display name.
62 pub slug: String,
63 pub path: PathBuf,
64 }
65
66 /// List every `.db` file in the profiles directory, sorted by slug.
67 pub fn list_profiles() -> Result<Vec<Profile>, Error> {
68 list_profiles_in(&profiles_dir()?)
69 }
70
71 /// Same as [`list_profiles`] but rooted at an explicit directory (for tests).
72 pub fn list_profiles_in(dir: &Path) -> Result<Vec<Profile>, Error> {
73 let mut out = Vec::new();
74 if !dir.exists() {
75 return Ok(out);
76 }
77 for entry in fs::read_dir(dir)? {
78 let entry = entry?;
79 let path = entry.path();
80 if path.extension().and_then(|e| e.to_str()) == Some("db")
81 && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
82 {
83 out.push(Profile {
84 slug: stem.to_string(),
85 path,
86 });
87 }
88 }
89 out.sort_by(|a, b| a.slug.cmp(&b.slug));
90 Ok(out)
91 }
92
93 /// Create a new profile file at `<dir>/<slug>.db` and initialize its `meta`
94 /// row. Errors if the file already exists.
95 pub fn create_profile_in(
96 dir: &Path,
97 display_name: &str,
98 unit: LoadUnit,
99 ) -> Result<(Profile, Db), Error> {
100 let slug = slugify(display_name);
101 let path = dir.join(format!("{slug}.db"));
102 if path.exists() {
103 return Err(Error::Io(std::io::Error::new(
104 std::io::ErrorKind::AlreadyExists,
105 format!("profile file already exists: {}", path.display()),
106 )));
107 }
108 fs::create_dir_all(dir)?;
109 let db = Db::open(&path)?;
110 db.init_profile(display_name, unit)?;
111 Ok((Profile { slug, path }, db))
112 }
113
114 #[cfg(test)]
115 mod tests {
116 use super::*;
117
118 fn tmp() -> tempdir::Handle {
119 tempdir::mkdtemp()
120 }
121
122 #[test]
123 fn slugify_examples() {
124 assert_eq!(slugify("self"), "self");
125 assert_eq!(slugify("Jane Doe"), "jane-doe");
126 assert_eq!(slugify(" Weird Name!! "), "weird-name");
127 assert_eq!(slugify("---"), "profile");
128 assert_eq!(slugify(""), "profile");
129 assert_eq!(slugify("a b c d"), "a-b-c-d");
130 }
131
132 #[test]
133 fn list_profiles_empty_dir_ok() {
134 let t = tmp();
135 assert_eq!(list_profiles_in(t.path()).unwrap(), vec![]);
136 }
137
138 #[test]
139 fn create_and_list_round_trip() {
140 let t = tmp();
141 let (p, _db) = create_profile_in(t.path(), "self", LoadUnit::Kg).unwrap();
142 assert_eq!(p.slug, "self");
143 let listed = list_profiles_in(t.path()).unwrap();
144 assert_eq!(listed.len(), 1);
145 assert_eq!(listed[0].slug, "self");
146 }
147
148 #[test]
149 fn create_profile_writes_meta_row() {
150 let t = tmp();
151 let (_, db) = create_profile_in(t.path(), "Jane Doe", LoadUnit::Lb).unwrap();
152 assert_eq!(db.profile_name().unwrap().as_deref(), Some("Jane Doe"));
153 assert_eq!(db.unit().unwrap(), Some(LoadUnit::Lb));
154 }
155
156 #[test]
157 fn create_profile_rejects_collision() {
158 let t = tmp();
159 create_profile_in(t.path(), "self", LoadUnit::Kg).unwrap();
160 let err = create_profile_in(t.path(), "SELF", LoadUnit::Kg);
161 assert!(err.is_err(), "second create with same slug must error");
162 }
163
164 #[test]
165 fn list_ignores_non_db_files() {
166 let t = tmp();
167 std::fs::write(t.path().join("notes.txt"), b"x").unwrap();
168 std::fs::write(t.path().join("readme"), b"x").unwrap();
169 create_profile_in(t.path(), "self", LoadUnit::Kg).unwrap();
170 let listed = list_profiles_in(t.path()).unwrap();
171 assert_eq!(listed.len(), 1);
172 assert_eq!(listed[0].slug, "self");
173 }
174 }
175
176 /// Tiny tempdir helper for tests; keeps the dep tree small.
177 #[cfg(test)]
178 mod tempdir {
179 use std::path::{Path, PathBuf};
180 use std::sync::atomic::{AtomicU64, Ordering};
181 use std::time::{SystemTime, UNIX_EPOCH};
182
183 static COUNTER: AtomicU64 = AtomicU64::new(0);
184
185 pub struct Handle(PathBuf);
186
187 impl Handle {
188 pub fn path(&self) -> &Path {
189 &self.0
190 }
191 }
192
193 impl Drop for Handle {
194 fn drop(&mut self) {
195 let _ = std::fs::remove_dir_all(&self.0);
196 }
197 }
198
199 pub fn mkdtemp() -> Handle {
200 let nanos = SystemTime::now()
201 .duration_since(UNIX_EPOCH)
202 .unwrap()
203 .as_nanos();
204 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
205 let dir =
206 std::env::temp_dir().join(format!("ripgrow-test-{nanos}-{n}-{}", std::process::id()));
207 std::fs::create_dir_all(&dir).unwrap();
208 Handle(dir)
209 }
210 }
211