//! Profile file discovery on disk. //! //! One SQLite file per profile at //! `~/Library/Application Support/ripgrow/profiles/.db`. This module //! turns display names into filesystem-safe slugs, resolves the profile //! directory, lists existing profiles, and opens (creating on demand) a //! profile by name. use std::fs; use std::path::{Path, PathBuf}; use crate::db::Db; use crate::values::LoadUnit; use crate::error::Error; /// Return the profiles directory, creating it if missing. /// /// macOS: `~/Library/Application Support/ripgrow/profiles/`. Other platforms /// fall back to the OS "data dir" per the `dirs` crate. The whole tree is /// created if any component is missing. pub fn profiles_dir() -> Result { let base = dirs::data_dir().ok_or_else(|| { Error::Io(std::io::Error::other("could not resolve OS data dir")) })?; let dir = base.join("ripgrow").join("profiles"); fs::create_dir_all(&dir)?; Ok(dir) } /// Turn a display name ("Jane Doe", "self") into a filesystem-safe slug. /// /// Rules: lowercase, ASCII letters/digits/hyphen only, whitespace and other /// characters collapse into single hyphens, no leading/trailing hyphens. /// Empty input becomes `"profile"`. pub fn slugify(name: &str) -> String { let mut out = String::with_capacity(name.len()); let mut prev_hyphen = true; for c in name.chars() { let ch = c.to_ascii_lowercase(); if ch.is_ascii_alphanumeric() { out.push(ch); prev_hyphen = false; } else if !prev_hyphen { out.push('-'); prev_hyphen = true; } } while out.ends_with('-') { out.pop(); } if out.is_empty() { "profile".to_string() } else { out } } /// A profile file on disk. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Profile { /// The slug (filename stem). Not necessarily the display name. pub slug: String, pub path: PathBuf, } /// List every `.db` file in the profiles directory, sorted by slug. pub fn list_profiles() -> Result, Error> { list_profiles_in(&profiles_dir()?) } /// Same as [`list_profiles`] but rooted at an explicit directory (for tests). pub fn list_profiles_in(dir: &Path) -> Result, Error> { let mut out = Vec::new(); if !dir.exists() { return Ok(out); } for entry in fs::read_dir(dir)? { let entry = entry?; let path = entry.path(); if path.extension().and_then(|e| e.to_str()) == Some("db") && let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { out.push(Profile { slug: stem.to_string(), path, }); } } out.sort_by(|a, b| a.slug.cmp(&b.slug)); Ok(out) } /// Create a new profile file at `/.db` and initialize its `meta` /// row. Errors if the file already exists. pub fn create_profile_in( dir: &Path, display_name: &str, unit: LoadUnit, ) -> Result<(Profile, Db), Error> { let slug = slugify(display_name); let path = dir.join(format!("{slug}.db")); if path.exists() { return Err(Error::Io(std::io::Error::new( std::io::ErrorKind::AlreadyExists, format!("profile file already exists: {}", path.display()), ))); } fs::create_dir_all(dir)?; let db = Db::open(&path)?; db.init_profile(display_name, unit)?; Ok((Profile { slug, path }, db)) } #[cfg(test)] mod tests { use super::*; fn tmp() -> tempdir::Handle { tempdir::mkdtemp() } #[test] fn slugify_examples() { assert_eq!(slugify("self"), "self"); assert_eq!(slugify("Jane Doe"), "jane-doe"); assert_eq!(slugify(" Weird Name!! "), "weird-name"); assert_eq!(slugify("---"), "profile"); assert_eq!(slugify(""), "profile"); assert_eq!(slugify("a b c d"), "a-b-c-d"); } #[test] fn list_profiles_empty_dir_ok() { let t = tmp(); assert_eq!(list_profiles_in(t.path()).unwrap(), vec![]); } #[test] fn create_and_list_round_trip() { let t = tmp(); let (p, _db) = create_profile_in(t.path(), "self", LoadUnit::Kg).unwrap(); assert_eq!(p.slug, "self"); let listed = list_profiles_in(t.path()).unwrap(); assert_eq!(listed.len(), 1); assert_eq!(listed[0].slug, "self"); } #[test] fn create_profile_writes_meta_row() { let t = tmp(); let (_, db) = create_profile_in(t.path(), "Jane Doe", LoadUnit::Lb).unwrap(); assert_eq!(db.profile_name().unwrap().as_deref(), Some("Jane Doe")); assert_eq!(db.unit().unwrap(), Some(LoadUnit::Lb)); } #[test] fn create_profile_rejects_collision() { let t = tmp(); create_profile_in(t.path(), "self", LoadUnit::Kg).unwrap(); let err = create_profile_in(t.path(), "SELF", LoadUnit::Kg); assert!(err.is_err(), "second create with same slug must error"); } #[test] fn list_ignores_non_db_files() { let t = tmp(); std::fs::write(t.path().join("notes.txt"), b"x").unwrap(); std::fs::write(t.path().join("readme"), b"x").unwrap(); create_profile_in(t.path(), "self", LoadUnit::Kg).unwrap(); let listed = list_profiles_in(t.path()).unwrap(); assert_eq!(listed.len(), 1); assert_eq!(listed[0].slug, "self"); } } /// Tiny tempdir helper for tests; keeps the dep tree small. #[cfg(test)] mod tempdir { use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; static COUNTER: AtomicU64 = AtomicU64::new(0); pub struct Handle(PathBuf); impl Handle { pub fn path(&self) -> &Path { &self.0 } } impl Drop for Handle { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } pub fn mkdtemp() -> Handle { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); let n = COUNTER.fetch_add(1, Ordering::Relaxed); let dir = std::env::temp_dir().join(format!("ripgrow-test-{nanos}-{n}-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); Handle(dir) } }