Skip to main content

typub_config/
project.rs

1use anyhow::{Context, Result, bail};
2use std::path::{Path, PathBuf};
3
4pub const CONFIG_FILE_NAME: &str = "typub.toml";
5
6pub fn find_project_root(start: &Path) -> Result<PathBuf> {
7    let start = if start.is_absolute() {
8        start.to_path_buf()
9    } else {
10        std::env::current_dir()?.join(start)
11    };
12
13    let mut current = start.as_path();
14    loop {
15        let config_path = current.join(CONFIG_FILE_NAME);
16        if config_path.exists() {
17            return Ok(current.to_path_buf());
18        }
19
20        match current.parent() {
21            Some(parent) => current = parent,
22            None => bail!(
23                "Could not find {} in any parent directory of {}",
24                CONFIG_FILE_NAME,
25                start.display()
26            ),
27        }
28    }
29}
30
31pub fn normalize_to_relative(path: &Path, project_root: &Path) -> Result<String> {
32    let abs_path = if path.is_absolute() {
33        path.to_path_buf()
34    } else {
35        project_root.join(path)
36    };
37
38    let abs_path = abs_path.canonicalize().unwrap_or(abs_path);
39    let project_root = project_root.canonicalize().with_context(|| {
40        format!(
41            "Failed to canonicalize project root: {}",
42            project_root.display()
43        )
44    })?;
45
46    let rel_path = abs_path
47        .strip_prefix(&project_root)
48        .with_context(|| {
49            format!(
50                "Asset outside project root: {}\nAssets must be within the project directory.\nConsider moving the asset into the project or creating a symlink.",
51                abs_path.display()
52            )
53        })?;
54
55    let normalized = path_to_forward_slash(rel_path);
56
57    if normalized.contains("..") {
58        bail!(
59            "Invalid path contains '..' after normalization: {}",
60            normalized
61        );
62    }
63
64    Ok(normalized)
65}
66
67pub fn resolve_from_relative(rel_path: &str, project_root: &Path) -> Result<PathBuf> {
68    let native_path = forward_slash_to_path(rel_path);
69    let abs_path = project_root.join(&native_path);
70
71    validate_within_project(&abs_path, project_root)?;
72
73    Ok(abs_path)
74}
75
76pub fn validate_within_project(path: &Path, project_root: &Path) -> Result<()> {
77    let abs_path = if path.is_absolute() {
78        path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
79    } else {
80        let joined = project_root.join(path);
81        joined.canonicalize().unwrap_or(joined)
82    };
83
84    let project_root = project_root.canonicalize().with_context(|| {
85        format!(
86            "Failed to canonicalize project root: {}",
87            project_root.display()
88        )
89    })?;
90
91    if !abs_path.starts_with(&project_root) {
92        bail!(
93            "Asset outside project root: {}\nAssets must be within the project directory.\nConsider moving the asset into the project or creating a symlink.",
94            abs_path.display()
95        );
96    }
97
98    Ok(())
99}
100
101fn path_to_forward_slash(path: &Path) -> String {
102    path.components()
103        .map(|c| c.as_os_str().to_string_lossy().to_string())
104        .collect::<Vec<_>>()
105        .join("/")
106}
107
108fn forward_slash_to_path(s: &str) -> PathBuf {
109    PathBuf::from(s.replace('/', std::path::MAIN_SEPARATOR_STR))
110}
111
112#[cfg(test)]
113mod tests {
114    #![allow(clippy::expect_used)]
115
116    use super::*;
117    use anyhow::Result;
118    use tempfile::TempDir;
119
120    fn setup_project() -> Result<(TempDir, PathBuf)> {
121        let dir = TempDir::new()?;
122        let project_root = dir.path().to_path_buf();
123        std::fs::write(project_root.join(CONFIG_FILE_NAME), "")?;
124        Ok((dir, project_root))
125    }
126
127    #[test]
128    fn test_find_project_root() -> Result<()> {
129        let (_dir, project_root) = setup_project()?;
130
131        let nested = project_root.join("posts/my-post");
132        std::fs::create_dir_all(&nested)?;
133
134        let found = find_project_root(&nested)?;
135        assert_eq!(found.canonicalize()?, project_root.canonicalize()?);
136        Ok(())
137    }
138}