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}