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, ¤t_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}