use crate::api::{PadzApi, PadzPaths};
use crate::config::PadzConfig;
use crate::error::PadzError;
use crate::model::Scope;
use crate::store::fs::FileStore;
use clapfig::{Clapfig, SearchMode, SearchPath};
use directories::{BaseDirs, ProjectDirs};
use std::path::{Path, PathBuf};
use uuid::Uuid;
pub struct PadzContext {
pub api: PadzApi<FileStore>,
pub scope: Scope,
pub config: PadzConfig,
}
pub fn find_project_root(cwd: &Path) -> Option<PathBuf> {
let home_dir = BaseDirs::new().map(|bd| bd.home_dir().to_path_buf());
let mut current = cwd.to_path_buf();
loop {
let git_dir = current.join(".git");
let padz_dir = current.join(".padz");
if git_dir.exists() && padz_dir.exists() {
return Some(current);
}
if let Some(ref home) = home_dir {
if ¤t == home {
return None;
}
}
match current.parent() {
Some(parent) if parent != current => {
current = parent.to_path_buf();
}
_ => {
return None;
}
}
}
}
pub fn resolve_link(padz_dir: &Path) -> crate::error::Result<Option<PathBuf>> {
let link_file = padz_dir.join("link");
if !link_file.exists() {
return Ok(None);
}
let raw = std::fs::read_to_string(&link_file)?;
let target_str = raw.trim();
if target_str.is_empty() {
return Err(PadzError::Store(format!(
"Link file at {} is empty",
link_file.display()
)));
}
let target = PathBuf::from(target_str);
let target = target.canonicalize().map_err(|_| {
PadzError::Store(format!(
"Link target '{}' does not exist or is not accessible",
target_str
))
})?;
let target_padz = if target.file_name().is_some_and(|n| n == ".padz") {
target
} else {
target.join(".padz")
};
if !target_padz.join("active").exists() {
return Err(PadzError::Store(format!(
"Link target '{}' has not been initialized (missing active/ directory). Run `padz init` there first.",
target_padz.display()
)));
}
if target_padz.join("link").exists() {
return Err(PadzError::Store(format!(
"Link target '{}' is itself a link. Chained links are not supported.",
target_padz.display()
)));
}
Ok(Some(target_padz))
}
pub fn initialize(cwd: &Path, use_global: bool, data_override: Option<PathBuf>) -> PadzContext {
let project_padz_dir = match data_override {
Some(path) => {
if path.file_name().is_some_and(|name| name == ".padz") {
path
} else {
path.join(".padz")
}
}
None => {
let detected = find_project_root(cwd)
.map(|root| root.join(".padz"))
.unwrap_or_else(|| cwd.join(".padz"));
match resolve_link(&detected) {
Ok(Some(linked)) => linked,
_ => detected,
}
}
};
let global_data_dir = std::env::var("PADZ_GLOBAL_DATA")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| {
let proj_dirs =
ProjectDirs::from("com", "padz", "padz").expect("Could not determine config dir");
proj_dirs.data_dir().to_path_buf()
});
let scope = if use_global {
Scope::Global
} else {
Scope::Project
};
let config_search_paths = if use_global {
vec![SearchPath::Path(global_data_dir.clone())]
} else {
vec![
SearchPath::Path(global_data_dir.clone()),
SearchPath::Path(project_padz_dir.clone()),
]
};
let config: PadzConfig = Clapfig::builder()
.app_name("padz")
.file_name("padz.toml")
.search_paths(config_search_paths)
.search_mode(SearchMode::Merge)
.load()
.unwrap_or_default();
let file_ext = config.file_ext();
migrate_if_needed(&project_padz_dir);
migrate_if_needed(&global_data_dir);
let store = FileStore::new_fs(Some(project_padz_dir.clone()), global_data_dir.clone())
.with_file_ext(&file_ext);
let paths = PadzPaths {
project: Some(project_padz_dir),
global: global_data_dir,
};
let api = PadzApi::new(store, paths);
PadzContext { api, scope, config }
}
fn migrate_if_needed(scope_root: &Path) {
let legacy_data = scope_root.join("data.json");
let active_dir = scope_root.join("active");
if !legacy_data.exists() || active_dir.exists() {
return;
}
if let Err(e) = migrate_flat_to_bucketed(scope_root) {
eprintln!(
"Warning: migration of {} failed: {}",
scope_root.display(),
e
);
}
}
fn migrate_flat_to_bucketed(scope_root: &Path) -> std::io::Result<()> {
use std::collections::HashMap;
use std::fs;
let legacy_data_path = scope_root.join("data.json");
let content = fs::read_to_string(&legacy_data_path)?;
let entries: HashMap<Uuid, serde_json::Value> = serde_json::from_str(&content)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let mut active_entries: HashMap<Uuid, serde_json::Value> = HashMap::new();
let mut deleted_entries: HashMap<Uuid, serde_json::Value> = HashMap::new();
for (id, mut value) in entries {
let is_deleted = value
.get("is_deleted")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if let Some(obj) = value.as_object_mut() {
obj.remove("is_deleted");
obj.remove("deleted_at");
}
if is_deleted {
deleted_entries.insert(id, value);
} else {
active_entries.insert(id, value);
}
}
let active_dir = scope_root.join("active");
let archived_dir = scope_root.join("archived");
let deleted_dir = scope_root.join("deleted");
fs::create_dir_all(&active_dir)?;
fs::create_dir_all(&archived_dir)?;
fs::create_dir_all(&deleted_dir)?;
let active_json =
serde_json::to_string_pretty(&active_entries).map_err(std::io::Error::other)?;
fs::write(active_dir.join("data.json"), active_json)?;
let deleted_json =
serde_json::to_string_pretty(&deleted_entries).map_err(std::io::Error::other)?;
fs::write(deleted_dir.join("data.json"), deleted_json)?;
fs::write(archived_dir.join("data.json"), "{}")?;
let all_active_ids: std::collections::HashSet<Uuid> = active_entries.keys().copied().collect();
let all_deleted_ids: std::collections::HashSet<Uuid> =
deleted_entries.keys().copied().collect();
let dir_entries = fs::read_dir(scope_root)?;
for entry in dir_entries {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
if !name.starts_with("pad-") {
continue;
}
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let uuid_part = stem.strip_prefix("pad-").unwrap_or("");
let Ok(id) = Uuid::parse_str(uuid_part) else {
continue;
};
let dest_dir = if all_deleted_ids.contains(&id) {
&deleted_dir
} else if all_active_ids.contains(&id) {
&active_dir
} else {
&active_dir
};
fs::rename(&path, dest_dir.join(name))?;
}
fs::remove_file(&legacy_data_path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_find_project_root_with_git_and_padz() {
let temp = TempDir::new().unwrap();
let root = temp.path();
fs::create_dir(root.join(".git")).unwrap();
fs::create_dir(root.join(".padz")).unwrap();
let result = find_project_root(root);
assert_eq!(result, Some(root.to_path_buf()));
}
#[test]
fn test_find_project_root_git_only_continues_up() {
let temp = TempDir::new().unwrap();
let parent = temp.path();
let child = parent.join("child-repo");
fs::create_dir(&child).unwrap();
fs::create_dir(parent.join(".git")).unwrap();
fs::create_dir(parent.join(".padz")).unwrap();
fs::create_dir(child.join(".git")).unwrap();
let result = find_project_root(&child);
assert_eq!(result, Some(parent.to_path_buf()));
}
#[test]
fn test_find_project_root_nested_repos_child_has_padz() {
let temp = TempDir::new().unwrap();
let parent = temp.path();
let child = parent.join("child-repo");
fs::create_dir(&child).unwrap();
fs::create_dir(parent.join(".git")).unwrap();
fs::create_dir(parent.join(".padz")).unwrap();
fs::create_dir(child.join(".git")).unwrap();
fs::create_dir(child.join(".padz")).unwrap();
let result = find_project_root(&child);
assert_eq!(result, Some(child.clone()));
}
#[test]
fn test_find_project_root_deep_nested() {
let temp = TempDir::new().unwrap();
let grandparent = temp.path();
let parent = grandparent.join("parent");
let child = parent.join("child");
fs::create_dir_all(&child).unwrap();
fs::create_dir(grandparent.join(".git")).unwrap();
fs::create_dir(grandparent.join(".padz")).unwrap();
let result = find_project_root(&child);
assert_eq!(result, Some(grandparent.to_path_buf()));
}
#[test]
fn test_find_project_root_no_git_no_padz() {
let temp = TempDir::new().unwrap();
let dir = temp.path().join("some").join("deep").join("path");
fs::create_dir_all(&dir).unwrap();
let result = find_project_root(&dir);
assert_eq!(result, None);
}
#[test]
fn test_find_project_root_padz_only_no_git() {
let temp = TempDir::new().unwrap();
let root = temp.path();
fs::create_dir(root.join(".padz")).unwrap();
let result = find_project_root(root);
assert_eq!(result, None);
}
#[test]
fn test_find_project_root_multiple_nested_git_only_repos() {
let temp = TempDir::new().unwrap();
let grandparent = temp.path();
let parent = grandparent.join("parent-repo");
let child = parent.join("child-repo");
fs::create_dir_all(&child).unwrap();
fs::create_dir(grandparent.join(".git")).unwrap();
fs::create_dir(grandparent.join(".padz")).unwrap();
fs::create_dir(parent.join(".git")).unwrap();
fs::create_dir(child.join(".git")).unwrap();
let result = find_project_root(&child);
assert_eq!(result, Some(grandparent.to_path_buf()));
let result = find_project_root(&parent);
assert_eq!(result, Some(grandparent.to_path_buf()));
}
#[test]
fn test_initialize_with_data_override_ending_in_padz() {
let temp = TempDir::new().unwrap();
let repo = temp.path();
fs::create_dir(repo.join(".git")).unwrap();
fs::create_dir(repo.join(".padz")).unwrap();
let override_dir = temp.path().join("custom-data").join(".padz");
fs::create_dir_all(&override_dir).unwrap();
let ctx = initialize(repo, false, Some(override_dir.clone()));
assert_eq!(ctx.api.paths().project, Some(override_dir));
assert_eq!(ctx.scope, crate::model::Scope::Project);
}
#[test]
fn test_initialize_with_data_override_not_ending_in_padz() {
let temp = TempDir::new().unwrap();
let repo = temp.path();
fs::create_dir(repo.join(".git")).unwrap();
fs::create_dir(repo.join(".padz")).unwrap();
let override_dir = temp.path().join("custom-project");
fs::create_dir_all(&override_dir).unwrap();
let ctx = initialize(repo, false, Some(override_dir.clone()));
assert_eq!(ctx.api.paths().project, Some(override_dir.join(".padz")));
assert_eq!(ctx.scope, crate::model::Scope::Project);
}
#[test]
fn test_initialize_without_override_uses_detection() {
let temp = TempDir::new().unwrap();
let repo = temp.path();
fs::create_dir(repo.join(".git")).unwrap();
fs::create_dir(repo.join(".padz")).unwrap();
let ctx = initialize(repo, false, None);
assert_eq!(ctx.api.paths().project, Some(repo.join(".padz")));
assert_eq!(ctx.scope, crate::model::Scope::Project);
}
#[test]
fn test_initialize_data_override_with_global_flag() {
let temp = TempDir::new().unwrap();
let repo = temp.path();
fs::create_dir(repo.join(".git")).unwrap();
fs::create_dir(repo.join(".padz")).unwrap();
let override_dir = temp.path().join("custom-data").join(".padz");
fs::create_dir_all(&override_dir).unwrap();
let ctx = initialize(repo, true, Some(override_dir.clone()));
assert_eq!(ctx.api.paths().project, Some(override_dir));
assert_eq!(ctx.scope, crate::model::Scope::Global);
}
#[test]
fn test_initialize_data_override_from_unrelated_directory() {
let temp = TempDir::new().unwrap();
let project = temp.path().join("project");
fs::create_dir_all(project.join(".padz")).unwrap();
fs::create_dir(project.join(".git")).unwrap();
let workdir = temp.path().join("workdir");
fs::create_dir_all(&workdir).unwrap();
let ctx = initialize(&workdir, false, Some(project.join(".padz")));
assert_eq!(ctx.api.paths().project, Some(project.join(".padz")));
}
#[test]
fn test_migration_flat_to_bucketed() {
let temp = TempDir::new().unwrap();
let root = temp.path().join(".padz");
fs::create_dir_all(&root).unwrap();
let id1 = Uuid::new_v4();
let id2 = Uuid::new_v4();
let legacy_data = serde_json::json!({
id1.to_string(): {
"id": id1.to_string(),
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"is_pinned": false,
"title": "Active Pad",
"is_deleted": false
},
id2.to_string(): {
"id": id2.to_string(),
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"is_pinned": false,
"title": "Deleted Pad",
"is_deleted": true,
"deleted_at": "2024-01-02T00:00:00Z"
}
});
fs::write(
root.join("data.json"),
serde_json::to_string_pretty(&legacy_data).unwrap(),
)
.unwrap();
fs::write(root.join(format!("pad-{}.txt", id1)), "Active content").unwrap();
fs::write(root.join(format!("pad-{}.txt", id2)), "Deleted content").unwrap();
fs::write(root.join("tags.json"), "[]").unwrap();
migrate_if_needed(&root);
assert!(!root.join("data.json").exists());
assert!(root.join("active").is_dir());
assert!(root.join("archived").is_dir());
assert!(root.join("deleted").is_dir());
let active_data: std::collections::HashMap<Uuid, serde_json::Value> =
serde_json::from_str(&fs::read_to_string(root.join("active/data.json")).unwrap())
.unwrap();
assert_eq!(active_data.len(), 1);
assert!(active_data.contains_key(&id1));
assert!(active_data[&id1].get("is_deleted").is_none());
let deleted_data: std::collections::HashMap<Uuid, serde_json::Value> =
serde_json::from_str(&fs::read_to_string(root.join("deleted/data.json")).unwrap())
.unwrap();
assert_eq!(deleted_data.len(), 1);
assert!(deleted_data.contains_key(&id2));
assert!(deleted_data[&id2].get("is_deleted").is_none());
assert!(deleted_data[&id2].get("deleted_at").is_none());
assert!(root
.join("active")
.join(format!("pad-{}.txt", id1))
.exists());
assert!(root
.join("deleted")
.join(format!("pad-{}.txt", id2))
.exists());
assert!(!root.join(format!("pad-{}.txt", id1)).exists());
assert!(!root.join(format!("pad-{}.txt", id2)).exists());
assert!(root.join("tags.json").exists());
}
#[test]
fn test_migration_idempotent() {
let temp = TempDir::new().unwrap();
let root = temp.path().join(".padz");
fs::create_dir_all(root.join("active")).unwrap();
fs::write(root.join("data.json"), "{}").unwrap();
migrate_if_needed(&root);
assert!(root.join("data.json").exists());
}
#[test]
fn test_migration_no_data_json() {
let temp = TempDir::new().unwrap();
let root = temp.path().join(".padz");
fs::create_dir_all(&root).unwrap();
migrate_if_needed(&root);
assert!(!root.join("active").exists());
}
#[test]
fn test_migration_all_active() {
let temp = TempDir::new().unwrap();
let root = temp.path().join(".padz");
fs::create_dir_all(&root).unwrap();
let id = Uuid::new_v4();
let legacy_data = serde_json::json!({
id.to_string(): {
"id": id.to_string(),
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"is_pinned": false,
"title": "Active"
}
});
fs::write(
root.join("data.json"),
serde_json::to_string_pretty(&legacy_data).unwrap(),
)
.unwrap();
fs::write(root.join(format!("pad-{}.txt", id)), "Content").unwrap();
migrate_if_needed(&root);
let active_data: std::collections::HashMap<Uuid, serde_json::Value> =
serde_json::from_str(&fs::read_to_string(root.join("active/data.json")).unwrap())
.unwrap();
assert_eq!(active_data.len(), 1);
let deleted_data: std::collections::HashMap<Uuid, serde_json::Value> =
serde_json::from_str(&fs::read_to_string(root.join("deleted/data.json")).unwrap())
.unwrap();
assert_eq!(deleted_data.len(), 0);
}
#[test]
fn test_resolve_link_follows_link_file() {
let temp = TempDir::new().unwrap();
let target = temp.path().join("project-a");
fs::create_dir_all(target.join(".padz").join("active")).unwrap();
fs::create_dir_all(target.join(".padz").join("archived")).unwrap();
fs::create_dir_all(target.join(".padz").join("deleted")).unwrap();
let source_padz = temp.path().join("project-b").join(".padz");
fs::create_dir_all(&source_padz).unwrap();
fs::write(
source_padz.join("link"),
target.canonicalize().unwrap().to_str().unwrap(),
)
.unwrap();
let result = resolve_link(&source_padz).unwrap();
assert!(result.is_some());
assert_eq!(
result.unwrap(),
target.canonicalize().unwrap().join(".padz")
);
}
#[test]
fn test_resolve_link_no_link_file() {
let temp = TempDir::new().unwrap();
let padz_dir = temp.path().join(".padz");
fs::create_dir_all(&padz_dir).unwrap();
let result = resolve_link(&padz_dir).unwrap();
assert!(result.is_none());
}
#[test]
fn test_resolve_link_broken_target() {
let temp = TempDir::new().unwrap();
let padz_dir = temp.path().join(".padz");
fs::create_dir_all(&padz_dir).unwrap();
fs::write(padz_dir.join("link"), "/nonexistent/path").unwrap();
let result = resolve_link(&padz_dir);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("does not exist"));
}
#[test]
fn test_resolve_link_chained_link() {
let temp = TempDir::new().unwrap();
let target = temp.path().join("project-a");
fs::create_dir_all(target.join(".padz").join("active")).unwrap();
fs::write(target.join(".padz").join("link"), "/some/other/path").unwrap();
let source_padz = temp.path().join("project-b").join(".padz");
fs::create_dir_all(&source_padz).unwrap();
fs::write(
source_padz.join("link"),
target.canonicalize().unwrap().to_str().unwrap(),
)
.unwrap();
let result = resolve_link(&source_padz);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("itself a link"));
}
#[test]
fn test_resolve_link_uninitialized_target() {
let temp = TempDir::new().unwrap();
let target = temp.path().join("project-a");
fs::create_dir_all(target.join(".padz")).unwrap();
let source_padz = temp.path().join("project-b").join(".padz");
fs::create_dir_all(&source_padz).unwrap();
fs::write(
source_padz.join("link"),
target.canonicalize().unwrap().to_str().unwrap(),
)
.unwrap();
let result = resolve_link(&source_padz);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("not been initialized"));
}
#[test]
fn test_initialize_follows_link() {
let temp = TempDir::new().unwrap();
let project_a = temp.path().join("project-a");
fs::create_dir(project_a.join(".git")).unwrap_or_default();
fs::create_dir_all(&project_a).unwrap();
fs::create_dir(project_a.join(".git")).unwrap();
fs::create_dir_all(project_a.join(".padz").join("active")).unwrap();
fs::create_dir_all(project_a.join(".padz").join("archived")).unwrap();
fs::create_dir_all(project_a.join(".padz").join("deleted")).unwrap();
let project_b = temp.path().join("project-b");
fs::create_dir_all(&project_b).unwrap();
fs::create_dir(project_b.join(".git")).unwrap();
fs::create_dir_all(project_b.join(".padz")).unwrap();
fs::write(
project_b.join(".padz").join("link"),
project_a.canonicalize().unwrap().to_str().unwrap(),
)
.unwrap();
let ctx = initialize(&project_b, false, None);
assert_eq!(
ctx.api.paths().project,
Some(project_a.canonicalize().unwrap().join(".padz"))
);
}
}