gglib_core/paths/
platform.rs

1//! Platform-specific path detection and resolution.
2//!
3//! This module contains private helpers for detecting the runtime environment
4//! (local repo vs installed binary) and resolving platform-appropriate paths.
5//! Public API is exposed through sibling modules.
6
7use std::env;
8use std::fs;
9use std::path::PathBuf;
10
11use super::error::PathError;
12
13/// Detect if we are running from the local repository.
14///
15/// Returns `Some(path)` if we are in a dev environment or running a release build
16/// from within the source repo (e.g. `make setup`).
17/// Returns `None` if we are running a standalone binary (e.g. installed via cargo install,
18/// downloaded, or bundled as a macOS/Linux app).
19#[allow(clippy::unnecessary_wraps)] // Option is needed for release builds
20pub(super) fn detect_local_repo() -> Option<PathBuf> {
21    let repo_root = PathBuf::from(env!("GGLIB_REPO_ROOT"));
22
23    #[cfg(debug_assertions)]
24    {
25        // In debug mode, always assume we want to use the repo we are building from
26        Some(repo_root)
27    }
28
29    #[cfg(not(debug_assertions))]
30    {
31        // In release mode, check if this binary was built from a local repo.
32
33        // First, verify the repo path exists and looks like a valid gglib repo
34        if !repo_root.exists()
35            || (!repo_root.join(".git").exists() && !repo_root.join("Cargo.toml").exists())
36        {
37            return None;
38        }
39
40        // Strategy 1: Check for the .gglib_repo_path marker file created by build.rs
41        let marker_file = repo_root.join("data").join(".gglib_repo_path");
42        if marker_file.exists() {
43            if let Ok(contents) = fs::read_to_string(&marker_file) {
44                let marker_path = contents.trim();
45                if marker_path == repo_root.to_string_lossy() {
46                    return Some(repo_root);
47                }
48            }
49        }
50
51        // Strategy 2 (fallback): Check if executable is inside the repo
52        if let Ok(exe_path) = env::current_exe() {
53            if let Ok(canonical_exe) = exe_path.canonicalize() {
54                if let Ok(canonical_repo) = repo_root.canonicalize() {
55                    if canonical_exe.starts_with(&canonical_repo) {
56                        return Some(repo_root);
57                    }
58                }
59            }
60        }
61
62        None
63    }
64}
65
66/// Check if we are running from a pre-built binary (not from the source repo).
67///
68/// Returns `true` if this is a standalone/installed binary.
69/// Returns `false` if running from the source repository.
70pub fn is_prebuilt_binary() -> bool {
71    detect_local_repo().is_none()
72}
73
74/// Get the root directory for application data (database, config).
75///
76/// Resolution order:
77/// 1. `GGLIB_DATA_DIR` environment variable (highest priority)
78/// 2. Local repository (if running from source)
79/// 3. System data directory (e.g., `~/.local/share/gglib`)
80pub fn data_root() -> Result<PathBuf, PathError> {
81    // 1. Runtime override (highest priority)
82    if let Ok(path) = env::var("GGLIB_DATA_DIR") {
83        return Ok(PathBuf::from(path));
84    }
85
86    // 2. Try local repo (e.g. make setup)
87    if let Some(repo) = detect_local_repo() {
88        return Ok(repo);
89    }
90
91    // 3. Default to system data directory
92    let data_dir = dirs::data_local_dir().ok_or(PathError::NoDataDir)?;
93
94    let root = data_dir.join("gglib");
95
96    // Ensure it exists
97    if !root.exists() {
98        fs::create_dir_all(&root).map_err(|e| PathError::CreateFailed {
99            path: root.clone(),
100            reason: e.to_string(),
101        })?;
102    }
103
104    Ok(root)
105}
106
107/// Get the root directory for application resources (binaries, static assets).
108///
109/// Resolution order:
110/// 1. `GGLIB_RESOURCE_DIR` environment variable
111/// 2. Local repository (if running from source)
112/// 3. Falls back to data root
113pub fn resource_root() -> Result<PathBuf, PathError> {
114    // 1. Runtime override
115    if let Ok(path) = env::var("GGLIB_RESOURCE_DIR") {
116        return Ok(PathBuf::from(path));
117    }
118
119    // 2. Try local repo
120    if let Some(repo) = detect_local_repo() {
121        return Ok(repo);
122    }
123
124    // 3. Fallback to system data directory
125    data_root()
126}
127
128/// Normalize a user-provided path, expanding `~` and making it absolute.
129pub(super) fn normalize_user_path(raw: &str) -> Result<PathBuf, PathError> {
130    let trimmed = raw.trim();
131    if trimmed.is_empty() {
132        return Err(PathError::EmptyPath);
133    }
134
135    let expanded = if trimmed.starts_with("~/") || trimmed == "~" {
136        let home = dirs::home_dir().ok_or(PathError::NoHomeDir)?;
137        if trimmed == "~" {
138            home
139        } else {
140            home.join(trimmed.trim_start_matches("~/"))
141        }
142    } else {
143        PathBuf::from(trimmed)
144    };
145
146    if expanded.is_absolute() {
147        Ok(expanded)
148    } else {
149        env::current_dir()
150            .map(|cwd| cwd.join(expanded))
151            .map_err(|e| PathError::CurrentDirError(e.to_string()))
152    }
153}