ito_config/ito_dir/
mod.rs

1//! Ito working directory discovery.
2//!
3//! This module answers: "where is the `.ito/` directory for this project?".
4//! It mirrors the precedence rules from the TypeScript implementation.
5
6use std::path::{Path, PathBuf};
7
8use ito_common::fs::{FileSystem, StdFs};
9
10use crate::{ConfigContext, load_global_config_fs, load_repo_project_path_override_fs};
11
12/// Determine the configured Ito working directory name.
13///
14/// This returns the directory name (not a full path). It consults repo-local
15/// configuration first, then global config, then falls back to `.ito`.
16pub fn get_ito_dir_name(project_root: &Path, ctx: &ConfigContext) -> String {
17    get_ito_dir_name_fs(&StdFs, project_root, ctx)
18}
19
20/// Like [`get_ito_dir_name`], but uses an injected file-system.
21pub fn get_ito_dir_name_fs<F: FileSystem>(
22    fs: &F,
23    project_root: &Path,
24    ctx: &ConfigContext,
25) -> String {
26    // Priority order matches TS:
27    // 1. Repo-level ito.json projectPath
28    // 2. Repo-level .ito.json projectPath
29    // 3. Global config (~/.config/ito/config.json) projectPath
30    // 4. Default: '.ito'
31    if let Some(project_path) = load_repo_project_path_override_fs(fs, project_root)
32        && let Some(project_path) = sanitize_ito_dir_name(&project_path)
33    {
34        return project_path;
35    }
36
37    if let Some(project_path) = load_global_config_fs(fs, ctx).project_path
38        && let Some(project_path) = sanitize_ito_dir_name(&project_path)
39    {
40        return project_path;
41    }
42
43    ".ito".to_string()
44}
45
46/// Resolve the `.ito/` path for `project_root`.
47pub fn get_ito_path(project_root: &Path, ctx: &ConfigContext) -> PathBuf {
48    get_ito_path_fs(&StdFs, project_root, ctx)
49}
50
51/// Resolve the Ito directory path for a project using an injected filesystem.
52///
53/// This absolutizes and lexically normalizes `project_root` (falling back to a lossy
54/// normalization if the current working directory cannot be determined) and appends
55/// the Ito directory name selected from repository overrides, global configuration, or the default.
56///
57/// # Examples
58///
59/// ```no_run
60/// use ito_common::fs::StdFs;
61/// use ito_config::ConfigContext;
62/// use ito_config::ito_dir::get_ito_path_fs;
63/// use std::path::Path;
64///
65/// let fs = StdFs;
66/// let ctx = ConfigContext::default();
67/// let ito_path = get_ito_path_fs(&fs, Path::new("some/project"), &ctx);
68/// assert!(ito_path.ends_with(".ito"));
69/// ```
70pub fn get_ito_path_fs<F: FileSystem>(fs: &F, project_root: &Path, ctx: &ConfigContext) -> PathBuf {
71    let root = absolutize_and_normalize_lossy(project_root);
72    root.join(get_ito_dir_name_fs(fs, &root, ctx))
73}
74
75/// Resolves a possibly-relative path to an absolute, lexically normalized form.
76///
77/// If `input` is absolute it is normalized in place; otherwise it is joined
78/// onto the current working directory before normalization. The result has no
79/// `.` or `..` components and does not access the filesystem, so it is safe to
80/// use for display even when the path does not exist.
81///
82/// # Errors
83///
84/// Returns an `io::Error` when the current directory cannot be determined and
85/// `input` is relative.
86///
87/// # Examples
88///
89/// ```
90/// use ito_config::ito_dir::absolutize_and_normalize;
91/// use std::path::Path;
92///
93/// let abs = absolutize_and_normalize(Path::new(".")).unwrap();
94/// assert!(abs.is_absolute());
95/// ```
96pub fn absolutize_and_normalize(input: &Path) -> std::io::Result<PathBuf> {
97    let abs = if input.is_absolute() {
98        input.to_path_buf()
99    } else {
100        std::env::current_dir()?.join(input)
101    };
102
103    Ok(lexical_normalize(&abs))
104}
105
106/// Produce an absolute, lexically normalized PathBuf, falling back to lexical normalization if the current working directory cannot be determined.
107///
108/// The result is a canonicalized form of `input` where `.` and `..` components are resolved lexically. If obtaining the current directory fails, this function returns the lexically normalized `input` without attempting to make it absolute.
109///
110/// # Examples
111///
112/// ```ignore
113/// use std::path::Path;
114///
115/// let p = super::absolutize_and_normalize_lossy(Path::new("foo/./bar"));
116/// assert!(p.ends_with(Path::new("foo/bar")));
117/// ```
118fn absolutize_and_normalize_lossy(input: &Path) -> PathBuf {
119    absolutize_and_normalize(input).unwrap_or_else(|_| lexical_normalize(input))
120}
121
122/// Lexically normalizes a path by resolving `.` and `..` components without accessing the filesystem.
123///
124/// This performs purely lexical simplification: it removes `.` segments, collapses `..` where possible,
125/// preserves rooted prefixes, and never queries the filesystem.
126///
127/// # Examples
128///
129/// ```
130/// use ito_config::ito_dir::lexical_normalize;
131/// use std::path::{Path, PathBuf};
132///
133/// let p = Path::new("a/./b/../c");
134/// assert_eq!(lexical_normalize(p), PathBuf::from("a/c"));
135///
136/// let abs = Path::new("/a/b/../c");
137/// assert_eq!(lexical_normalize(abs), PathBuf::from("/a/c"));
138///
139/// let up = Path::new("../a/../b");
140/// assert_eq!(lexical_normalize(up), PathBuf::from("../b"));
141/// ```
142pub fn lexical_normalize(path: &Path) -> PathBuf {
143    use std::path::Component;
144
145    let mut out = PathBuf::new();
146    let mut stack: Vec<std::ffi::OsString> = Vec::new();
147    let mut rooted = false;
148
149    for c in path.components() {
150        match c {
151            Component::Prefix(p) => {
152                out.push(p.as_os_str());
153            }
154            Component::RootDir => {
155                rooted = true;
156            }
157            Component::CurDir => {}
158            Component::ParentDir => {
159                if let Some(last) = stack.last()
160                    && last != ".."
161                {
162                    stack.pop();
163                    continue;
164                }
165                if !rooted {
166                    stack.push(std::ffi::OsString::from(".."));
167                }
168            }
169            Component::Normal(seg) => {
170                stack.push(seg.to_os_string());
171            }
172        }
173    }
174
175    if rooted {
176        out.push(std::path::MAIN_SEPARATOR.to_string());
177    }
178    for seg in stack {
179        out.push(seg);
180    }
181
182    out
183}
184
185fn sanitize_ito_dir_name(input: &str) -> Option<String> {
186    let input = input.trim();
187    if input.is_empty() {
188        return None;
189    }
190
191    if input.len() > 128 {
192        return None;
193    }
194
195    if input.contains('/') || input.contains('\\') || input.contains("..") {
196        return None;
197    }
198
199    if Path::new(input).is_absolute() {
200        return None;
201    }
202
203    Some(input.to_string())
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn get_ito_dir_name_defaults_to_dot_ito() {
212        let td = tempfile::tempdir().unwrap();
213        let ctx = ConfigContext::default();
214        assert_eq!(get_ito_dir_name(td.path(), &ctx), ".ito");
215    }
216
217    #[test]
218    fn repo_config_overrides_global_config() {
219        let td = tempfile::tempdir().unwrap();
220        std::fs::write(
221            td.path().join("ito.json"),
222            "{\"projectPath\":\".repo-ito\"}",
223        )
224        .unwrap();
225
226        let home = tempfile::tempdir().unwrap();
227        let cfg_dir = home.path().join(".config/ito");
228        std::fs::create_dir_all(&cfg_dir).unwrap();
229        std::fs::write(
230            cfg_dir.join("config.json"),
231            "{\"projectPath\":\".global-ito\"}",
232        )
233        .unwrap();
234
235        let ctx = ConfigContext {
236            xdg_config_home: None,
237            home_dir: Some(home.path().to_path_buf()),
238            project_dir: None,
239        };
240
241        assert_eq!(get_ito_dir_name(td.path(), &ctx), ".repo-ito");
242    }
243
244    #[test]
245    fn dot_repo_config_overrides_repo_config() {
246        let td = tempfile::tempdir().unwrap();
247        std::fs::write(
248            td.path().join("ito.json"),
249            "{\"projectPath\":\".repo-ito\"}",
250        )
251        .unwrap();
252        std::fs::write(
253            td.path().join(".ito.json"),
254            "{\"projectPath\":\".dot-ito\"}",
255        )
256        .unwrap();
257
258        let ctx = ConfigContext::default();
259        assert_eq!(get_ito_dir_name(td.path(), &ctx), ".dot-ito");
260    }
261
262    #[test]
263    fn get_ito_path_normalizes_dotdot_segments() {
264        let td = tempfile::tempdir().unwrap();
265        let repo = td.path();
266        std::fs::create_dir_all(repo.join("a")).unwrap();
267        std::fs::create_dir_all(repo.join("b")).unwrap();
268
269        let ctx = ConfigContext::default();
270        let p = repo.join("a/../b");
271
272        let ito_path = get_ito_path(&p, &ctx);
273        assert!(ito_path.ends_with("b/.ito"));
274    }
275
276    #[test]
277    fn invalid_repo_project_path_falls_back_to_default() {
278        let td = tempfile::tempdir().unwrap();
279        std::fs::write(
280            td.path().join("ito.json"),
281            "{\"projectPath\":\"../escape\"}",
282        )
283        .unwrap();
284
285        let ctx = ConfigContext::default();
286        assert_eq!(get_ito_dir_name(td.path(), &ctx), ".ito");
287    }
288
289    #[test]
290    fn sanitize_rejects_path_separators_and_overlong_values() {
291        assert_eq!(sanitize_ito_dir_name(".ito"), Some(".ito".to_string()));
292        assert_eq!(sanitize_ito_dir_name("../x"), None);
293        assert_eq!(sanitize_ito_dir_name("a/b"), None);
294        assert_eq!(sanitize_ito_dir_name("a\\b"), None);
295        assert_eq!(sanitize_ito_dir_name(&"a".repeat(129)), None);
296    }
297}