Skip to main content

track_core/
paths.rs

1use std::env;
2use std::path::{Path, PathBuf};
3
4use crate::errors::{ErrorCode, TrackError};
5
6pub const DEFAULT_BACKEND_STATE_DIR: &str = "~/.track/backend";
7pub const DEFAULT_CLI_CONFIG_PATH: &str = "~/.config/track/cli.json";
8pub const DEFAULT_CONFIG_PATH: &str = "~/.config/track/config.json";
9pub const DEFAULT_DATA_DIR: &str = "~/.track/issues";
10pub const DEFAULT_LEGACY_ROOT_DIR: &str = "~/.track";
11pub const REVIEW_DIRECTORY_NAME: &str = "reviews";
12pub const REMOTE_AGENT_DIRECTORY_NAME: &str = "remote-agent";
13pub const DISPATCH_DIRECTORY_NAME: &str = ".dispatches";
14pub const DATABASE_FILE_NAME: &str = "track.sqlite";
15
16fn home_dir() -> Option<PathBuf> {
17    env::var_os("HOME").map(PathBuf::from)
18}
19
20pub fn expand_home_path(path_value: &str) -> PathBuf {
21    match path_value {
22        "~" => home_dir().unwrap_or_else(|| PathBuf::from("~")),
23        value if value.starts_with("~/") => home_dir()
24            .unwrap_or_else(|| PathBuf::from("~"))
25            .join(&value[2..]),
26        value => PathBuf::from(value),
27    }
28}
29
30pub fn resolve_path_from_invocation_dir(path_value: &str) -> Result<PathBuf, TrackError> {
31    let current_directory = env::current_dir().map_err(|error| {
32        TrackError::new(
33            ErrorCode::InvalidConfig,
34            format!("Could not resolve a configured path from the current directory: {error}"),
35        )
36    })?;
37
38    Ok(resolve_path_from_base_dir(path_value, &current_directory))
39}
40
41pub fn resolve_path_from_config_file(
42    path_value: &str,
43    file_path: &Path,
44) -> Result<PathBuf, TrackError> {
45    let base_dir = file_path.parent().ok_or_else(|| {
46        TrackError::new(
47            ErrorCode::InvalidConfig,
48            format!(
49                "Could not resolve a configured path relative to config file {}.",
50                collapse_home_path(file_path)
51            ),
52        )
53    })?;
54
55    Ok(resolve_path_from_base_dir(path_value, base_dir))
56}
57
58pub fn resolve_optional_command_path_from_config_file(
59    path_value: Option<&str>,
60    file_path: &Path,
61) -> Result<Option<String>, TrackError> {
62    let Some(path_value) = path_value else {
63        return Ok(None);
64    };
65
66    if path_value.starts_with("~/")
67        || path_value.starts_with("./")
68        || path_value.starts_with("../")
69        || path_value.contains('/')
70    {
71        return Ok(Some(path_to_string(&resolve_path_from_config_file(
72            path_value, file_path,
73        )?)));
74    }
75
76    Ok(Some(path_value.to_owned()))
77}
78
79pub fn get_config_path() -> Result<PathBuf, TrackError> {
80    resolve_path_from_invocation_dir(
81        &env::var("TRACK_CONFIG_PATH").unwrap_or_else(|_| DEFAULT_CONFIG_PATH.to_owned()),
82    )
83}
84
85pub fn get_cli_config_path() -> Result<PathBuf, TrackError> {
86    resolve_path_from_invocation_dir(
87        &env::var("TRACK_CLI_CONFIG_PATH").unwrap_or_else(|_| DEFAULT_CLI_CONFIG_PATH.to_owned()),
88    )
89}
90
91pub fn get_data_dir() -> Result<PathBuf, TrackError> {
92    resolve_path_from_invocation_dir(
93        &env::var("TRACK_DATA_DIR").unwrap_or_else(|_| DEFAULT_DATA_DIR.to_owned()),
94    )
95}
96
97pub fn get_backend_state_dir() -> Result<PathBuf, TrackError> {
98    resolve_path_from_invocation_dir(
99        &env::var("TRACK_STATE_DIR").unwrap_or_else(|_| DEFAULT_BACKEND_STATE_DIR.to_owned()),
100    )
101}
102
103pub fn get_backend_database_path() -> Result<PathBuf, TrackError> {
104    Ok(get_backend_state_dir()?.join(DATABASE_FILE_NAME))
105}
106
107pub fn get_backend_secrets_dir() -> Result<PathBuf, TrackError> {
108    Ok(get_backend_state_dir()?.join(REMOTE_AGENT_DIRECTORY_NAME))
109}
110
111pub fn get_backend_managed_remote_agent_key_path() -> Result<PathBuf, TrackError> {
112    Ok(get_backend_secrets_dir()?.join("id_ed25519"))
113}
114
115pub fn get_backend_managed_remote_agent_known_hosts_path() -> Result<PathBuf, TrackError> {
116    Ok(get_backend_secrets_dir()?.join("known_hosts"))
117}
118
119pub fn get_legacy_root_dir() -> Result<PathBuf, TrackError> {
120    resolve_path_from_invocation_dir(
121        &env::var("TRACK_LEGACY_ROOT").unwrap_or_else(|_| DEFAULT_LEGACY_ROOT_DIR.to_owned()),
122    )
123}
124
125pub fn get_legacy_config_path() -> Result<PathBuf, TrackError> {
126    resolve_path_from_invocation_dir(
127        &env::var("TRACK_LEGACY_CONFIG_PATH").unwrap_or_else(|_| DEFAULT_CONFIG_PATH.to_owned()),
128    )
129}
130
131pub fn get_track_root_dir() -> Result<PathBuf, TrackError> {
132    let data_dir = get_data_dir()?;
133    Ok(data_dir.parent().map(Path::to_path_buf).unwrap_or(data_dir))
134}
135
136pub fn get_models_dir() -> Result<PathBuf, TrackError> {
137    Ok(get_track_root_dir()?.join("models"))
138}
139
140pub fn get_remote_agent_dir() -> Result<PathBuf, TrackError> {
141    Ok(get_track_root_dir()?.join(REMOTE_AGENT_DIRECTORY_NAME))
142}
143
144pub fn get_managed_remote_agent_key_path() -> Result<PathBuf, TrackError> {
145    Ok(get_remote_agent_dir()?.join("id_ed25519"))
146}
147
148pub fn get_managed_remote_agent_known_hosts_path() -> Result<PathBuf, TrackError> {
149    Ok(get_remote_agent_dir()?.join("known_hosts"))
150}
151
152pub fn get_dispatches_dir() -> Result<PathBuf, TrackError> {
153    Ok(get_data_dir()?.join(DISPATCH_DIRECTORY_NAME))
154}
155
156pub fn get_reviews_dir() -> Result<PathBuf, TrackError> {
157    Ok(get_track_root_dir()?.join(REVIEW_DIRECTORY_NAME))
158}
159
160pub fn get_review_dispatches_dir() -> Result<PathBuf, TrackError> {
161    Ok(get_reviews_dir()?.join(DISPATCH_DIRECTORY_NAME))
162}
163
164pub fn collapse_home_path(path: &Path) -> String {
165    match home_dir() {
166        Some(home) if path == home => "~".to_owned(),
167        Some(home) if path.starts_with(&home) => {
168            let relative = path.strip_prefix(home).unwrap_or(path);
169            let relative = path_to_string(relative).trim_start_matches('/').to_owned();
170
171            if relative.is_empty() {
172                "~".to_owned()
173            } else {
174                format!("~/{relative}")
175            }
176        }
177        _ => path_to_string(path),
178    }
179}
180
181pub fn collapse_path_value(path_value: &str) -> String {
182    collapse_home_path(&expand_home_path(path_value))
183}
184
185pub fn path_to_string(path: &Path) -> String {
186    path.to_string_lossy().into_owned()
187}
188
189fn resolve_path_from_base_dir(path_value: &str, base_dir: &Path) -> PathBuf {
190    let expanded = expand_home_path(path_value);
191    if expanded.is_absolute() {
192        return expanded;
193    }
194
195    base_dir.join(expanded)
196}
197
198#[cfg(test)]
199mod tests {
200    use std::env;
201    use std::path::Path;
202
203    use super::{collapse_home_path, collapse_path_value};
204
205    #[test]
206    fn collapses_home_relative_paths_with_a_slash() {
207        let home = env::var("HOME").expect("tests require HOME");
208        let rendered = collapse_home_path(Path::new(&home).join(".track/issues").as_path());
209
210        assert_eq!(rendered, "~/.track/issues");
211    }
212
213    #[test]
214    fn collapses_home_prefixed_string_values() {
215        let home = env::var("HOME").expect("tests require HOME");
216        let config_path = Path::new(&home).join(".config/track/config.json");
217
218        assert_eq!(
219            collapse_path_value(&config_path.to_string_lossy()),
220            "~/.config/track/config.json"
221        );
222    }
223}