Skip to main content

gen_mdbook_summary/
lib.rs

1use std::{
2    collections::HashSet,
3    path::{Path, PathBuf},
4};
5
6use anyhow::{Context, bail};
7use log::info;
8
9/// 对路径进行简单转义,只处理空格
10/// mdbook 的 SUMMARY.md 使用文件系统路径,不是 URL,不需要完整的 URL 编码
11fn escape_path(path: &str) -> String {
12    // mdbook 支持 Unicode 文件名,只需转义空格
13    path.replace(' ', "%20")
14}
15
16#[derive(Debug)]
17pub struct SummaryItem {
18    name: String,
19    path: PathBuf,          // 相对路径
20    absolute_path: PathBuf, // 绝对路径(用于文件系统操作)
21    introduction: Option<String>,
22    chapters: Vec<SummaryItem>,
23}
24
25impl SummaryItem {
26    pub fn new(
27        dir: &str,
28        ignore: &Ignore,
29        base_dir: &Path,
30        output_file: Option<&Path>,
31    ) -> anyhow::Result<Self> {
32        info!("try to create SummaryItem from {}", dir);
33        let mut chapters = Vec::new();
34        let absolute_path = Path::new(dir).canonicalize()?;
35
36        // 计算相对路径
37        let relative_path = absolute_path
38            .strip_prefix(base_dir)
39            .map(|p| p.to_path_buf())
40            .unwrap_or_else(|_| absolute_path.clone());
41
42        // 如果相对路径为空,使用 "." 表示当前目录
43        let relative_path = if relative_path.as_os_str().is_empty() {
44            PathBuf::from(".")
45        } else {
46            relative_path
47        };
48
49        let dir = absolute_path.display().to_string();
50        let meta = std::fs::metadata(&dir)?;
51        let name = Self::item_name_from_path_str(&dir)?.to_string();
52
53        if meta.is_file() {
54            return Ok(Self {
55                name,
56                path: relative_path,
57                absolute_path,
58                introduction: None,
59                chapters,
60            });
61        }
62
63        // check if the dir is a directory
64        if !meta.is_dir() {
65            bail!("{} is neither a file nor a directory", dir);
66        }
67        info!("{dir}");
68        // check introduction
69        let mut introduction: Option<String> = None;
70        let names = vec!["README.md", "readme.md", "README", "readme"];
71        // check if the introduction file exists
72        for readme_name in names {
73            let path = format!("{}/{}", dir, readme_name);
74            if Path::new(&path).exists() && !ignore.is_ignore(&path) {
75                // 存储相对路径
76                let intro_absolute = Path::new(&path).canonicalize()?;
77                let intro_relative = intro_absolute
78                    .strip_prefix(base_dir)
79                    .map(|p| p.to_path_buf())
80                    .unwrap_or_else(|_| intro_absolute.clone());
81
82                // 如果相对路径为空,使用 "." 表示当前目录
83                let intro_relative = if intro_relative.as_os_str().is_empty() {
84                    PathBuf::from(".")
85                } else {
86                    intro_relative
87                };
88
89                introduction = Some(intro_relative.to_string_lossy().to_string());
90                break;
91            }
92        }
93
94        for entry in std::fs::read_dir(&dir)? {
95            let entry = entry?;
96            let path = entry.path();
97            let path_str = &path.display().to_string();
98            info!("{}", path_str);
99
100            // 自动忽略 SUMMARY.md 文件(通过路径比较,不依赖文件是否存在)
101            if let Some(output) = output_file {
102                // 检查是否是输出文件(比较绝对路径)
103                let output_absolute = if output.is_absolute() {
104                    output.to_path_buf()
105                } else {
106                    std::env::current_dir().unwrap().join(output)
107                };
108
109                // 规范化路径进行比较
110                let entry_absolute = if path.is_absolute() {
111                    path.to_path_buf()
112                } else {
113                    std::env::current_dir().unwrap().join(&path)
114                };
115
116                if entry_absolute == output_absolute {
117                    info!("path_str {} is output file, skip", path_str);
118                    continue;
119                }
120            }
121
122            if ignore.is_ignore(path_str) {
123                info!("ignore {}", path_str);
124                continue;
125            }
126            if path_str.ends_with("readme.md") || path_str.ends_with("README.md") {
127                info!("path_str {} is readme.md,skip", path_str);
128                continue;
129            }
130            chapters.push(Self::new(path_str, ignore, base_dir, output_file)?);
131        }
132        let path = relative_path;
133        Ok(Self {
134            name,
135            path,
136            absolute_path,
137            introduction,
138            chapters,
139        })
140    }
141
142    pub fn sort(&mut self) {
143        self.chapters.sort_by(|a, b| a.name.cmp(&b.name));
144        for chapter in self.chapters.iter_mut() {
145            chapter.sort();
146        }
147    }
148
149    pub fn gen_summary(&self) -> anyhow::Result<String> {
150        let mut summary = String::new();
151        summary.push_str("# Summary\n\n");
152        if self.absolute_path.is_dir() {
153            for chapter in &self.chapters {
154                summary.push('\n');
155                summary.push_str(&chapter.item(0)?);
156            }
157        } else {
158            let path_str = self
159                .path
160                .to_str()
161                .with_context(|| format!("[{}:{}]", file!(), line!(),))?;
162            summary.push_str(&format!("- [{}]({})", self.name, path_str));
163        }
164        Ok(summary)
165    }
166
167    pub fn item(&self, depth: usize) -> anyhow::Result<String> {
168        let mut item = String::new();
169        for _ in 0..depth {
170            item.push('\t');
171        }
172        let path_str = self
173            .path
174            .to_str()
175            .with_context(|| format!("[{}:{}]", file!(), line!(),))?;
176
177        // 只转义空格,mdbook 支持 Unicode 文件名
178        let encoded_path = escape_path(path_str);
179
180        if self.absolute_path.is_dir() {
181            if let Some(introduction) = &self.introduction {
182                let encoded_intro = escape_path(introduction);
183                item.push_str(format!("- [{}]({})", &self.name, encoded_intro).as_str());
184            } else {
185                item.push_str(format!("- [{}]()", &self.name).as_str());
186            }
187            for chapter in self.chapters.iter() {
188                item.push('\n');
189                item.push_str(&chapter.item(depth + 1)?);
190            }
191        } else {
192            item.push_str(&format!("- [{}]({})", &self.name, encoded_path));
193        }
194        info!("{item}");
195        Ok(item)
196    }
197
198    fn item_name_from_path_str(path_name: &str) -> anyhow::Result<String> {
199        // 使用 Path 来获取文件名,兼容所有平台
200        let path = Path::new(path_name);
201        let name = path
202            .file_name()
203            .and_then(|n| n.to_str())
204            .ok_or(anyhow::anyhow!(
205                "[{}:{}:{}]invalid name: {}",
206                file!(),
207                line!(),
208                column!(),
209                path_name
210            ))?
211            .trim();
212
213        // remove name's extension
214        let name = if path.is_dir() {
215            name.to_string()
216        } else {
217            let name: Vec<&str> = name.split(".").collect();
218            name[..name.len() - 1].join(".")
219        };
220
221        Ok(name)
222    }
223}
224
225#[derive(Debug)]
226pub struct Ignore {
227    unignored: HashSet<String>,
228}
229
230impl Ignore {
231    pub fn new(dir: &str, ignore_file: &str) -> anyhow::Result<Self> {
232        use ignore::WalkBuilder;
233        let mut unignored = HashSet::new();
234        for result in WalkBuilder::new(dir)
235            .add_custom_ignore_filename(ignore_file)
236            .build()
237        {
238            let result = result?;
239            let path = result.path().canonicalize()?;
240            let path = path
241                .to_str()
242                .with_context(|| format!("[{}:{}]", file!(), line!(),))?;
243            unignored.insert(path.to_string());
244        }
245        Ok(Self { unignored })
246    }
247
248    pub fn is_ignore(&self, path: &str) -> bool {
249        !self.unignored.contains(path)
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::SummaryItem;
256
257    #[test]
258    fn t_item_name() {
259        // enter temp dir
260        let temp_dir = std::env::temp_dir();
261        let temp_dir = temp_dir.join("test_item_name");
262        std::fs::create_dir_all(&temp_dir).unwrap();
263        std::env::set_current_dir(&temp_dir).unwrap();
264
265        fn test(p: &str, is_dir: bool, expect: &str) {
266            if is_dir {
267                std::fs::create_dir_all(p).unwrap();
268            }
269            let name = SummaryItem::item_name_from_path_str(p).unwrap();
270            if is_dir {
271                std::fs::remove_dir_all(p).unwrap();
272            }
273            assert_eq!(name, expect);
274        }
275
276        test("README.md", false, "README");
277        test("One.AA.md", false, "One.AA");
278        test("blog/Pro/readme.md", false, "readme");
279    }
280}