diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index edf8d3a..865abde 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,48 +14,75 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] - rust: [stable] + rust: [stable, 1.80.0] + fail-fast: false + max-parallel: 2 steps: - name: Checkout code uses: actions/checkout@v3 + with: + fetch-depth: 1 - - name: Set up Rust - uses: actions-rs/toolchain@v1 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable with: toolchain: ${{ matrix.rust }} - override: true + components: rustfmt, clippy - - name: Build project - uses: actions-rs/cargo@v1 + - name: Rust Cache + uses: actions/cache@v3 with: - command: build - args: --release + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-rust-${{ matrix.rust }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-rust-${{ matrix.rust }}- + ${{ runner.os }}-rust- + + - name: Check formatting + run: cargo fmt --all -- --check + continue-on-error: true + + - name: Run clippy + run: cargo clippy -- -D warnings + continue-on-error: true + + - name: Build project + run: cargo build --release + timeout-minutes: 20 + if: always() - name: Run tests - uses: actions-rs/cargo@v1 - with: - command: test - args: --release + run: cargo test --release + timeout-minutes: 30 + if: success() - - name: Package binary (Unix-like) - if: runner.os != 'Windows' - run: | - mkdir -p dist - cp target/release/treegen dist/ - tar -czvf treegen-${{ runner.os }}.tar.gz dist/treegen + - name: Create dist directory + run: mkdir -p dist + shell: bash - - name: Package binary (Windows) - if: runner.os == 'Windows' + - name: Package binary + shell: bash run: | - mkdir dist - Copy-Item target\release\treegen.exe dist\ - Compress-Archive -Path dist\treegen.exe -DestinationPath treegen-${{ runner.os }}.zip + if [ "$RUNNER_OS" == "Windows" ]; then + cp target/release/treegen.exe LICENSE README.md dist/ + 7z a "treegen-${RUNNER_OS}-${GITHUB_SHA}.zip" ./dist/* + else + cp target/release/treegen LICENSE README.md dist/ + tar -czvf "treegen-${RUNNER_OS}-${GITHUB_SHA}.tar.gz" dist/ + fi - name: Upload artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 + if: success() with: - name: treegen-${{ runner.os }} + name: treegen-${{ runner.os }}-${{ matrix.rust }}-${{ github.sha }} path: | - treegen-${{ runner.os }}.tar.gz - treegen-${{ runner.os }}.zip + treegen-*.tar.gz + treegen-*.zip + retention-days: 5 diff --git a/Cargo.lock b/Cargo.lock index 39a15e2..b679d8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -137,6 +137,31 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.6" @@ -157,6 +182,12 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "equivalent" version = "1.0.2" @@ -185,6 +216,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "indexmap" version = "2.9.0" @@ -230,6 +267,16 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -305,6 +352,26 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "regex" version = "1.11.1" @@ -449,6 +516,8 @@ dependencies = [ "anyhow", "clap", "json5", + "num_cpus", + "rayon", "regex", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 957c461..6618ad9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,13 +7,18 @@ description = "A CLI tool to generate file trees from Markdown/YAML/JSON definit license = "MIT" repository = "https://github.com/AnNingUI/treegen" +[[bin]] +name = "bench" +path = "src/bench.rs" [dependencies] anyhow = "1.0.98" clap = { version = "4.5.39", features = ["derive"] } json5 = "0.4.1" +num_cpus = "1.17.0" +rayon = "1.11.0" regex = "1.11.1" serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.140" serde_yaml = "0.9.34" -toml = "0.5" # 添加 toml crate 依赖 +toml = "0.5" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6457ccd --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 AnNingUI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/src/bench.rs b/src/bench.rs new file mode 100644 index 0000000..ea3457d --- /dev/null +++ b/src/bench.rs @@ -0,0 +1,71 @@ +use anyhow::Result; +use std::path::PathBuf; +use std::time::Instant; + +use treegen::{create_fs_parallel, CreateFsOptions, Node}; + +/// 构造一个包含 N 个文件的 Node 树,每个文件大小为 file_size 字节 +fn generate_tree(num_files: usize, file_size: usize) -> Node { + let mut root = Node::new_dir("bench".to_string()); + let content = "A".repeat(file_size); + + for i in 0..num_files { + let name = format!("file_{i}.txt"); + root.children + .push(Node::new_file(name, Some(content.clone()))); + } + root +} + +fn run_bench(num_files: usize, file_size: usize, jobs: usize) -> Result<()> { + let root = generate_tree(num_files, file_size); + let out_dir = PathBuf::from(format!("bench_out_{num_files}_{file_size}_{jobs}")); + + let opts = CreateFsOptions { + dry_run: false, + verbose: false, + force: true, + mode: 0o644, + jobs, + batch_log: 0, + }; + + let start = Instant::now(); + create_fs_parallel(&out_dir, &root, &opts)?; + let elapsed = start.elapsed(); + + let total_bytes = num_files * file_size; + let mb = total_bytes as f64 / (1024.0 * 1024.0); + let secs = elapsed.as_secs_f64(); + let throughput = mb / secs; + + println!( + "Files: {:>6}, Size: {:>6} KB, Threads: {:>2} | Time: {:>6.2} s | {:.2} MB/s", + num_files, + file_size / 1024, + jobs, + secs, + throughput + ); + + Ok(()) +} + +fn main() -> Result<()> { + // 测试不同规模 + let configs = vec![ + (1000, 1024), // 1k 个文件,每个 1KB + (10_000, 1024), // 1w 个文件,每个 1KB + (1000, 1024 * 1024), // 1k 个文件,每个 1MB + ]; + + let threads = vec![1, 4, 8]; + + for &(num_files, file_size) in &configs { + for &j in &threads { + run_bench(num_files, file_size, j)?; + } + } + + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..0039bf9 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,540 @@ +use anyhow::{bail, Context, Result}; +use clap::Parser; +use regex::Regex; +use serde::Deserialize; +use std::borrow::Cow; +use std::collections::BTreeMap; +use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +// 添加 rayon 导入 +use rayon::prelude::*; + +/// CLI 参数定义 +#[derive(Parser, Debug)] +#[command(name = "treegen")] +#[command(author = "AnNingUI <3533581512@qq.com>")] +#[command(version = "0.1.0")] +#[command( + about = "Generate file/folder trees from Markdown/YAML/JSON/TOML/JSON5 specifications", + long_about = None +)] +pub struct Args { + /// 要解析的一个或多个输入文件(支持 .md/.yaml/.yml/.json/.toml/.json5) + #[arg(required = true)] + pub input: Vec, + + /// 输出根目录(可选,默认是当前工作目录) + #[arg(short, long)] + pub out: Option, + + /// 仅预览将要创建的文件/目录,不写入磁盘 + #[arg(long)] + pub dry_run: bool, + + /// 打印详细日志(每个文件/目录创建情况) + #[arg(short, long)] + pub verbose: bool, + + /// 覆盖已存在的文件 + #[arg(long)] + pub force: bool, + + /// 如果输出目录已存在同名路径,先删除再创建(谨慎使用) + #[arg(long)] + pub clean: bool, + + /// 新建文件的权限(八进制,如 0o644,仅类 Unix 平台生效) + #[arg(long, default_value = "0o644")] + pub mode: String, + + /// 并行工作线程数(0 = 自动选择) + #[arg(short = 'j', long, default_value = "0")] + pub jobs: usize, + + /// 批量日志输出:每 N 个文件输出一次进度(0 = 禁用批量日志) + #[arg(long, default_value = "100")] + pub batch_log: usize, +} + +#[derive(Debug, Clone)] +pub struct CreateFsOptions { + pub dry_run: bool, + pub verbose: bool, + pub force: bool, + pub mode: u32, + pub jobs: usize, + pub batch_log: usize, +} + +/// 节点类型:目录或文件 +#[derive(Debug, Clone)] +pub enum NodeType { + Dir, + File, +} + +// parse_file trait 用于解析输入文件,返回 Node 树 +pub trait ParseFile { + fn parse_file(path: &Path) -> Result; +} + +/// 树节点结构 +#[derive(Debug, Clone)] +pub struct Node { + pub name: String, + node_type: NodeType, + pub children: Vec, + pub content: Option, // 文件内容 +} + +struct MdParser; +struct YamlParser; +struct JsonParser; +struct Json5Parser; +struct TomlParser; + +impl Node { + pub fn new_file(name: String, content: Option) -> Self { + Node { + name, + node_type: NodeType::File, + children: Vec::new(), + content, + } + } + pub fn new_dir(name: String) -> Self { + Node { + name, + node_type: NodeType::Dir, + children: Vec::new(), + content: None, + } + } +} + +/// SerdeNode 用于反序列化 +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum SerdeNode { + Str(String), + Map(BTreeMap), +} + +/// 去除多行字符串的首尾空行 + 公共缩进 +fn dedent(s: &str) -> String { + let mut lines: Vec<&str> = s.lines().collect(); + // 去掉首尾纯空行 + while !lines.is_empty() && lines.first().unwrap().trim().is_empty() { + lines.remove(0); + } + while !lines.is_empty() && lines.last().unwrap().trim().is_empty() { + lines.pop(); + } + if lines.is_empty() { + return String::new(); + } + // 计算最小缩进 + let min_indent = lines + .iter() + .filter(|l| !l.trim().is_empty()) + .map(|l| l.chars().take_while(|&c| c == ' ').count()) + .min() + .unwrap_or(0); + // 去除缩进 + lines + .into_iter() + .map(|l| { + if l.len() >= min_indent { + l[min_indent..].to_string() + } else { + l.trim_start().to_string() + } + }) + .collect::>() + .join("\n") +} + +// 预编译正则 +static TREE_REGEX: OnceLock = OnceLock::new(); + +fn tree_regex() -> &'static Regex { + TREE_REGEX.get_or_init(|| { + Regex::new(r"^(?P(│ | )*)(?P├── |└── )?(?P.+)$") + .expect("Failed to compile tree regex") + }) +} + +/// 并行创建文件系统 +pub fn create_fs_parallel(base: &Path, root: &Node, opts: &CreateFsOptions) -> Result<()> { + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(if opts.jobs == 0 { + num_cpus::get() + } else { + opts.jobs + }) + .build() + .context("Failed to create thread pool")?; + + create_dirs_sequential(base, root, opts.dry_run, opts.verbose, opts.batch_log)?; + + pool.install(|| { + create_files_parallel( + base, + root, + opts.dry_run, + opts.verbose, + opts.force, + opts.mode, + opts.batch_log, + ) + }) +} + +/// 顺序创建目录结构 +fn create_dirs_sequential( + base: &Path, + root: &Node, + dry_run: bool, + verbose: bool, + batch_log: usize, +) -> Result<()> { + let mut stack = vec![(base.to_path_buf(), root)]; + let mut dir_count = 0; + + while let Some((parent, node)) = stack.pop() { + let path = if node.name.is_empty() { + parent + } else { + parent.join(&node.name) + }; + + if let NodeType::Dir = node.node_type { + dir_count += 1; + + // 批量日志输出 + if verbose && (batch_log == 0 || dir_count % batch_log == 0) { + eprintln!( + "{}Creating directory {}/...", + if dry_run { "[Dry-Run] " } else { "" }, + dir_count + ); + } else if verbose && batch_log == 1 { + eprintln!( + "{}Create directory: {}", + if dry_run { "[Dry-Run] " } else { "" }, + path.display() + ); + } + + if !dry_run { + fs::create_dir_all(&path) + .with_context(|| format!("Failed to create directory '{}'", path.display()))?; + } + // 子目录倒序入栈,保持原顺序 + for child in node.children.iter().rev() { + stack.push((path.clone(), child)); + } + } + } + + if verbose && batch_log > 1 { + eprintln!("✅ Created {dir_count} directories"); + } + + Ok(()) +} + +/// 并行创建文件 +fn create_files_parallel( + base: &Path, + root: &Node, + dry_run: bool, + verbose: bool, + force: bool, + _mode: u32, + batch_log: usize, +) -> Result<()> { + // 收集所有需要创建的文件 + let files = collect_files(base, root); + let total_files = files.len(); + + if dry_run { + if verbose { + match batch_log { + 0..=1 => { + for (path, _) in &files { + eprintln!("[Dry-Run] Create file: {}", path.display()); + } + } + _ => { + eprintln!("[Dry-Run] Would create {total_files} files"); + } + } + } + return Ok(()); + } + + // 并行处理文件创建 + files + .par_iter() + .enumerate() + .try_for_each(|(index, (path, content)): (usize, &(PathBuf, Option))| -> Result<(), anyhow::Error> { + // 批量日志输出 + if verbose && batch_log > 0 && index % batch_log == 0 { + eprintln!("Creating file {}/{}...", index + 1, total_files); + } else if verbose && batch_log == 0 { + eprintln!("Create file: {}", path.display()); + } + + if let Some(parent) = path.parent() { + // 目录应该已经存在,但以防万一 + fs::create_dir_all(parent).ok(); + } + + let content_str = content.as_deref().unwrap_or(""); + + // 如果文件已存在且未启用 --force,则报错 + if path.exists() && !force { + bail!("File '{}' already exists (use --force to overwrite)", path.display()); + } + + fs::write(path, content_str) + .with_context(|| format!("Failed to write file '{}'", path.display()))?; + + #[cfg(unix)] + fs::set_permissions(path, fs::Permissions::from_mode(_mode)) + .with_context(|| format!("Failed to set permissions for '{}'", path.display()))?; + + Ok(()) + })?; + if verbose && batch_log > 1 { + eprintln!("✅ Created {total_files} files"); + } + + Ok(()) +} + +/// 收集所有文件路径和内容 +fn collect_files(base: &Path, node: &Node) -> Vec<(PathBuf, Option)> { + let mut files = Vec::new(); + let mut stack = vec![(base.to_path_buf(), node)]; + + while let Some((parent, node)) = stack.pop() { + let path = if node.name.is_empty() { + parent + } else { + parent.join(&node.name) + }; + + match node.node_type { + NodeType::File => { + files.push((path, node.content.clone())); + } + NodeType::Dir => { + for child in node.children.iter().rev() { + stack.push((path.clone(), child)); + } + } + } + } + files +} + +impl SerdeNode { + fn to_node(&self, name: String) -> Node { + match self { + SerdeNode::Str(content) => { + let dedented = dedent(content); + Node::new_file(name, Some(dedented)) + } + SerdeNode::Map(map) => { + let mut dir = Node::new_dir(name); + for (k, v) in map { + dir.children.push(v.to_node(k.clone())); + } + dir + } + } + } +} + +impl MdParser { + fn parse_tree(lines: &[&str]) -> Result { + let mut root = Node::new_dir("".to_string()); + // 栈存 (level, 父节点在 children 中的索引路径) + let mut stack: Vec<(usize, Vec)> = vec![(0, Vec::new())]; + + let re = tree_regex(); + + for line in lines { + let line = line.trim_end(); + if line.is_empty() { + continue; + } + let caps = re + .captures(line) + .with_context(|| format!("Line '{line}' does not match Markdown tree format"))?; + + let indent_str = caps.name("indent").map_or("", |m| m.as_str()); + let indent_blocks = indent_str.chars().count() / 4; + let level = if caps.name("prefix").is_some() { + indent_blocks + 2 + } else { + indent_blocks + 1 + }; + + let name: Cow = { + let n = caps.name("name").unwrap().as_str().trim(); + if n.contains(':') { + Cow::Owned(n.replace(':', "_")) + } else { + Cow::Borrowed(n) + } + }; + + let node_type = if name.ends_with('/') { + NodeType::Dir + } else { + NodeType::File + }; + + // 弹出直到找到父节点 + while let Some(&(last_level, _)) = stack.last() { + if last_level >= level { + stack.pop(); + } else { + break; + } + } + + // 获取父节点可变引用 + let parent = { + let (_, ref indices) = stack.last().unwrap(); + let mut current: &mut Node = &mut root; + for &idx in indices { + current = &mut current.children[idx]; + } + current + }; + + // 添加新节点 + let new_node = if let NodeType::Dir = node_type { + Node::new_dir(name.into_owned()) + } else { + Node::new_file(name.into_owned(), None) + }; + parent.children.push(new_node); + + // 如果是目录,压入栈 + if let NodeType::Dir = node_type { + let mut new_indices = stack.last().unwrap().1.clone(); + new_indices.push(parent.children.len() - 1); + stack.push((level, new_indices)); + } + } + + Ok(root) + } +} + +impl ParseFile for MdParser { + fn parse_file(path: &Path) -> Result { + let content = fs::read_to_string(path) + .with_context(|| format!("Failed to read Markdown file '{}'", path.display()))?; + let lines: Vec<&str> = content.lines().collect(); + MdParser::parse_tree(&lines) + } +} + +impl ParseFile for YamlParser { + fn parse_file(path: &Path) -> Result { + let content = fs::read_to_string(path) + .with_context(|| format!("Failed to read YAML file '{}'", path.display()))?; + let data: BTreeMap = serde_yaml::from_str(&content) + .with_context(|| format!("Failed to parse YAML in '{}'", path.display()))?; + let mut root = Node::new_dir("".to_string()); + for (k, v) in data { + root.children.push(v.to_node(k)); + } + Ok(root) + } +} + +impl ParseFile for JsonParser { + fn parse_file(path: &Path) -> Result { + let content = fs::read_to_string(path) + .with_context(|| format!("Failed to read JSON file '{}'", path.display()))?; + let data: BTreeMap = serde_json::from_str(&content) + .with_context(|| format!("Failed to parse JSON in '{}'", path.display()))?; + let mut root = Node::new_dir("".to_string()); + for (k, v) in data { + root.children.push(v.to_node(k)); + } + Ok(root) + } +} + +impl ParseFile for Json5Parser { + fn parse_file(path: &Path) -> Result { + let raw = fs::read_to_string(path) + .with_context(|| format!("Failed to read JSON5 file '{}'", path.display()))?; + let data: BTreeMap = json5::from_str(&raw) + .with_context(|| format!("Failed to parse JSON5 in '{}'", path.display()))?; + let mut root = Node::new_dir("".to_string()); + for (k, v) in data { + root.children.push(v.to_node(k)); + } + Ok(root) + } +} + +impl ParseFile for TomlParser { + fn parse_file(path: &Path) -> Result { + let content = fs::read_to_string(path) + .with_context(|| format!("Failed to read TOML file '{}'", path.display()))?; + let data: BTreeMap = toml::from_str(&content) + .with_context(|| format!("Failed to parse TOML in '{}'", path.display()))?; + let mut root = Node::new_dir("".to_string()); + for (k, v) in data { + root.children.push(v.to_node(k)); + } + Ok(root) + } +} + +/// 并行解析输入文件,收集所有错误 +pub fn parse_input_files_parallel(input_paths: &[PathBuf], jobs: usize) -> Result> { + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(if jobs == 0 { num_cpus::get() } else { jobs }) + .build() + .context("Failed to create thread pool for parsing")?; + + pool.install(|| { + input_paths + .par_iter() + .map(|input_path| { + if !input_path.exists() { + bail!("Input file '{}' does not exist", input_path.display()); + } + + let ext = input_path + .extension() + .map(|e| e.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + + match ext.as_str() { + "md" => MdParser::parse_file(input_path), + "yaml" | "yml" => YamlParser::parse_file(input_path), + "json" => JsonParser::parse_file(input_path), + "toml" => TomlParser::parse_file(input_path), + "json5" => Json5Parser::parse_file(input_path), + _ => bail!("Unsupported file extension '{}'", input_path.display()), + } + }) + .collect() + }) +} diff --git a/src/main.rs b/src/main.rs index 331766c..e5f48d0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,484 +1,49 @@ -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result}; use clap::Parser; -use regex::Regex; -use serde::Deserialize; -#[cfg(unix)] -use std::os::unix::fs::PermissionsExt; -use std::{collections::BTreeMap, env, fs, path::PathBuf}; - -/// CLI 参数定义 -#[derive(Parser, Debug)] -#[command(name = "treegen")] -#[command(author = "AnNingUI <3533581512@qq.com>")] -#[command(version = "0.1.0")] -#[command( - about = "Generate file/folder trees from Markdown/YAML/JSON/TOML/JSON5 specifications", - long_about = None -)] -struct Args { - /// 要解析的一个或多个输入文件(支持 .md/.yaml/.yml/.json/.toml/.json5) - #[arg(required = true)] - input: Vec, - - /// 输出根目录(可选,默认是当前工作目录) - #[arg(short, long)] - out: Option, - - /// 仅预览将要创建的文件/目录,不写入磁盘 - #[arg(long)] - dry_run: bool, - - /// 打印详细日志(每个文件/目录创建情况) - #[arg(short, long)] - verbose: bool, - - /// 如果输出目录已存在同名路径,先删除再创建(谨慎使用) - #[arg(long)] - clean: bool, - - /// 新建文件的权限(八进制,如 0o644,仅类 Unix 平台生效) - #[arg(long, default_value = "0o644")] - mode: String, -} - -/// 节点类型:目录或文件 -#[derive(Debug)] -enum NodeType { - Dir, - File, -} - -/// 树节点结构 -#[derive(Debug)] -struct Node { - name: String, - node_type: NodeType, - children: Vec, - content: Option, // 用于 YAML/JSON/TOML/JSON5 中指定文件内容 -} - -impl Node { - /// 构造一个文件节点(可携带内容) - fn new_file(name: String, content: Option) -> Self { - Node { - name, - node_type: NodeType::File, - children: Vec::new(), - content, - } - } - /// 构造一个空目录节点 - fn new_dir(name: String) -> Self { - Node { - name, - node_type: NodeType::Dir, - children: Vec::new(), - content: None, - } - } -} - -/// === Markdown 树状目录解析 === -/// 示例: -/// project/ -/// ├── src/ -/// │ ├── main.rs -/// │ └── lib.rs -/// ├── Cargo.toml -/// └── README.md -fn parse_md_tree(lines: &[String]) -> Result { - // 根节点("" 表示从指定输出目录开始,不创建额外文件夹) - let mut root = Node::new_dir("".to_string()); - - // 栈:维护 (level, *mut Node) 以便附加子节点 - let mut stack: Vec<(usize, *mut Node)> = Vec::new(); - let root_ptr: *mut Node = &mut root as *mut Node; - stack.push((0, root_ptr)); - - // 正则匹配:捕获缩进(indent)、可选前缀(prefix)、以及名称(name) - let re = Regex::new(r"^(?P(│ | )*)(?P├── |└── )?(?P.+)$")?; - - for line in lines { - if line.trim().is_empty() { - continue; - } - let caps = re - .captures(line) - .with_context(|| format!("Line '{}' does not match Markdown tree format", line))?; - - // 计算 indent_blocks = 每 4 字符算一级 - let indent_str = caps.name("indent").map_or("", |m| m.as_str()); - let indent_blocks = indent_str.chars().count() / 4; - - // 如果有 prefix (“├── ” 或 “└── ”),层级 = indent_blocks + 2;否则 = indent_blocks + 1 - let level = if caps.name("prefix").is_some() { - indent_blocks + 2 - } else { - indent_blocks + 1 - }; - - let name = caps.name("name").unwrap().as_str().trim().to_string(); - let node_type = if name.ends_with('/') { - NodeType::Dir - } else { - NodeType::File - }; - - let child = Node { - name: name.clone(), - node_type, - children: Vec::new(), - content: None, // 移除内容填充功能 - }; - - // 弹出直到栈顶的 level < 当前 level - while stack.last().unwrap().0 >= level { - stack.pop(); - } - // 此时栈顶即为父节点 - let parent_ptr = stack.last().unwrap().1; - unsafe { - let parent_ref: &mut Node = &mut *parent_ptr; - parent_ref.children.push(child); - let last_idx = parent_ref.children.len() - 1; - if let NodeType::Dir = parent_ref.children[last_idx].node_type { - // 如果新节点是目录,把它压入栈 - let child_ptr: *mut Node = &mut parent_ref.children[last_idx] as *mut Node; - stack.push((level, child_ptr)); - } - } - } - - Ok(root) -} - -/// === YAML/JSON/TOML 解析 === -/// SerdeNode 用于反序列化: -/// - Str(String):代表文件内容 -/// - Map(BTreeMap<_, _>):代表目录及其子结构 -#[derive(Debug, Deserialize)] -#[serde(untagged)] -enum SerdeNode { - Str(String), - Map(BTreeMap), -} - -/// 将 SerdeNode 转为我们自己的 Node 结构 -fn serde_to_node(name: String, snode: &SerdeNode) -> Node { - match snode { - SerdeNode::Str(content) => Node::new_file(name, Some(content.clone())), - SerdeNode::Map(map) => { - let mut dir = Node::new_dir(name); - for (k, v) in map { - dir.children.push(serde_to_node(k.clone(), v)); - } - dir - } - } -} - -/// 从 YAML 文件中解析出 Node 树 -fn parse_yaml_file(path: &PathBuf) -> Result { - let content = fs::read_to_string(path) - .with_context(|| format!("Failed to read YAML file '{}'", path.display()))?; - let data: BTreeMap = serde_yaml::from_str(&content) - .with_context(|| format!("Failed to parse YAML in '{}'", path.display()))?; - let mut root = Node::new_dir("".to_string()); - for (k, v) in data { - root.children.push(serde_to_node(k, &v)); - } - Ok(root) -} - -/// 从 JSON 文件中解析出 Node 树 -fn parse_json_file(path: &PathBuf) -> Result { - let content = fs::read_to_string(path) - .with_context(|| format!("Failed to read JSON file '{}'", path.display()))?; - let data: BTreeMap = serde_json::from_str(&content) - .with_context(|| format!("Failed to parse JSON in '{}'", path.display()))?; - let mut root = Node::new_dir("".to_string()); - for (k, v) in data { - root.children.push(serde_to_node(k, &v)); - } - Ok(root) -} - -/// 从 TOML 文件中解析出 Node 树 -fn parse_toml_file(path: &PathBuf) -> Result { - let content = fs::read_to_string(path) - .with_context(|| format!("Failed to read TOML file '{}'", path.display()))?; - let data: BTreeMap = toml::from_str(&content) - .with_context(|| format!("Failed to parse TOML in '{}'", path.display()))?; - - let mut root = Node::new_dir("".to_string()); - for (key, value) in data { - root.children.push(parse_toml_node(key, &value)); - } - Ok(root) -} - -fn parse_toml_node(name: String, snode: &SerdeNode) -> Node { - match snode { - SerdeNode::Str(content) => Node::new_file(name, Some(content.clone())), - SerdeNode::Map(map) => { - let mut dir = Node::new_dir(name); - for (key, value) in map { - dir.children.push(parse_toml_node(key.clone(), value)); - } - dir - } - } -} - -/// dedent(): 去除多行字符串的首尾空行 + 公共缩进,保持内容整体对齐 -fn dedent(s: &str) -> String { - // 1. 按行拆分,去掉首尾纯空行 - let mut lines: Vec<&str> = s.lines().collect(); - // 去掉前导空行 - while !lines.is_empty() && lines.first().unwrap().trim().is_empty() { - lines.remove(0); - } - // 去掉末尾空行 - while !lines.is_empty() && lines.last().unwrap().trim().is_empty() { - lines.pop(); - } - if lines.is_empty() { - return String::new(); - } - // 2. 找到所有非空行的最小缩进数(以空格计) - let mut min_indent = usize::MAX; - for &line in &lines { - if line.trim().is_empty() { - continue; - } - let count = line.chars().take_while(|c| *c == ' ').count(); - if count < min_indent { - min_indent = count; - } - } - if min_indent == usize::MAX { - min_indent = 0; - } - // 3. 对每行去除前 min_indent 个空格 - let dedented: Vec = lines - .into_iter() - .map(|line| { - if line.len() >= min_indent { - line[min_indent..].to_string() - } else { - line.trim_start().to_string() - } - }) - .collect(); - dedented.join("\n") -} - -/// === JSON5 格式解析 === -/// 支持: -/// - 反引号(`…`)包裹多行字符串 -/// - 单/双引号字符串、无引号键、注释、末尾逗号等 JSON5 特性 -/// - 写法示例 (structure.json5): -/// ```json5 -/// // 顶层就是一个对象 -/// { -/// my_project: { -/// src: { -/// "main.rs": ` -/// fn main() { -/// println!("Hello from JSON5!"); -/// } -/// `, -/// "lib.rs": "" -/// }, -/// "Cargo.toml": ` -/// [package] -/// name = "my_project" -/// version = "0.1.0" -/// `, -/// "README.md": ` -/// # My Project -/// -/// 这是示例项目,通过 JSON5 定义生成。 -/// ` -/// } -/// } -/// ``` -/// 直接用 `json5::from_str` 解析时,内部会保留原样的多行文本,我们再对其 dedent 后输出。 -fn parse_json5_file(path: &PathBuf) -> Result { - // 1. 读取整个 .json5 文件内容 - let raw = fs::read_to_string(path) - .with_context(|| format!("Failed to read JSON5 file '{}'", path.display()))?; - - // 2. 我们需要先把所有反引号包裹的多行内容 dedent 后再交给 json5 解析。 - // 简单思路:扫描整个 raw,将 `…` 之间的内容先提取、dedent、再放回 raw 中。 - let mut output = String::new(); - let mut chars = raw.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '`' { - // 收集反引号内的内容 - let mut content = String::new(); - while let Some(&next_ch) = chars.peek() { - chars.next(); - if next_ch == '`' { - break; - } else { - content.push(next_ch); - } - } - // dedent 之后再放到 output:用三引号包裹以便 JSON5 理解多行? - // 但 json5 本身也支持反引号,此处只要保证「缩进对齐」,让 JSON5 解析时拿到干净的多行文本即可。 - let dedented = dedent(&content); // 修复反引号包裹内容的缩进问题 - output.push('`'); - output.push_str(&dedented); - output.push('`'); - } else { - output.push(ch); - } - } - - // 3. 用 json5 解析成 BTreeMap - let data: BTreeMap = json5::from_str(&output) - .with_context(|| format!("Failed to parse JSON5 in '{}'", path.display()))?; - - // 4. 转为 Node 树 - let mut root = Node::new_dir("".to_string()); - for (k, v) in data { - root.children.push(serde_to_node(k, &v)); - } - Ok(root) -} - -/// 从 Markdown 文件中解析 Node 树 -fn parse_md_file(path: &PathBuf) -> Result { - let content = fs::read_to_string(path) - .with_context(|| format!("Failed to read Markdown file '{}'", path.display()))?; - let lines: Vec = content.lines().map(|l| l.to_string()).collect(); - let sanitized_lines: Vec = lines - .iter() - .map(|line| line.replace(":", "_")) // 修复文件名语法问题 - .collect(); - parse_md_tree(&sanitized_lines) -} - -/// === 递归在磁盘上创建目录和文件 === -fn create_fs(base: &PathBuf, node: &Node, dry_run: bool, verbose: bool, _mode: u32) -> Result<()> { - // 如果 name 为空,则 base 本身;否则 base/ - let path = if node.name.is_empty() { - base.clone() - } else { - base.join(&node.name) - }; - - match node.node_type { - NodeType::Dir => { - if dry_run { - if verbose { - println!("[Dry-Run] Create directory: {}", path.display()); - } - } else { - if verbose { - println!("Create directory: {}", path.display()); - } - fs::create_dir_all(&path) - .with_context(|| format!("Failed to create directory '{}'", path.display()))?; - } - for child in node.children.iter() { - create_fs(&path, child, dry_run, verbose, _mode) - .with_context(|| format!("Failed under directory '{}'", path.display()))?; - } - } - NodeType::File => { - if let Some(parent) = path.parent() { - if !dry_run { - fs::create_dir_all(parent).ok(); - } else if verbose { - println!("[Dry-Run] Ensure parent dirs for: {}", path.display()); - } - } - if dry_run { - if verbose { - println!("[Dry-Run] Create file: {}", path.display()); - } - } else { - if verbose { - println!("Create file: {}", path.display()); - } - if let Some(content) = &node.content { - fs::write(&path, content) - .with_context(|| format!("Failed to write file '{}'", path.display()))?; - } else { - fs::write(&path, "").with_context(|| { - format!("Failed to create empty file '{}'", path.display()) - })?; - } - #[cfg(unix)] - { - fs::set_permissions(&path, fs::Permissions::from_mode(_mode)).with_context( - || format!("Failed to set permissions for '{}'", path.display()), - )?; - } - } - } - } - Ok(()) -} - +use std::{env, fs}; +use treegen::{create_fs_parallel, parse_input_files_parallel, Args, CreateFsOptions, Node}; fn main() -> Result<()> { - // 解析命令行参数 let args = Args::parse(); - // 确定输出目录:如果指定了 --out,就用它;否则用当前工作目录 - let out_dir = if let Some(dir) = args.out.clone() { - dir - } else { - env::current_dir().context("Failed to get current working directory")? - }; + let out_dir = args + .out + .unwrap_or_else(|| env::current_dir().expect("Failed to get current working directory")); - // 如果 --clean 并且 out_dir 存在,则先删除 if args.clean && out_dir.exists() { if args.verbose { - println!("Cleaning existing directory: {}", out_dir.display()); + eprintln!("Cleaning existing directory: {}", out_dir.display()); } fs::remove_dir_all(&out_dir) .with_context(|| format!("Failed to remove directory '{}'", out_dir.display()))?; } - // 确保输出目录存在 if !args.clean { fs::create_dir_all(&out_dir).with_context(|| { format!("Failed to create output directory '{}'", out_dir.display()) })?; } - // 解析 mode,如 "0o644" -> 0o644 let mode = u32::from_str_radix(args.mode.trim_start_matches("0o"), 8) .context("Invalid mode format; use octal like 0o644")?; - // 根节点:合并所有输入文件解析结果 - let mut root = Node::new_dir("".to_string()); + // 并行解析输入文件(收集所有错误) + let parsed_nodes = parse_input_files_parallel(&args.input, args.jobs)?; - for input_path in &args.input { - if !input_path.exists() { - bail!("Input file '{}' does not exist", input_path.display()); - } - let ext = input_path - .extension() - .map(|e| e.to_string_lossy().to_lowercase()) - .unwrap_or_default(); - let parsed = match ext.as_str() { - "md" => parse_md_file(input_path)?, - "yaml" | "yml" => parse_yaml_file(input_path)?, - "json" => parse_json_file(input_path)?, - "toml" => parse_toml_file(input_path)?, - "json5" => parse_json5_file(input_path)?, - _ => bail!("Unsupported file extension '{}'", input_path.display()), - }; - // 合并子节点 - root.children.extend(parsed.children); + let mut root = Node::new_dir("".to_string()); + for node in parsed_nodes { + root.children.extend(node.children); } - // 递归在 out_dir 下创建目录/文件 - create_fs(&out_dir, &root, args.dry_run, args.verbose, mode)?; + let opts = CreateFsOptions { + dry_run: args.dry_run, + verbose: args.verbose, + force: args.force, + mode, + jobs: args.jobs, + batch_log: args.batch_log, + }; + // 使用并行文件创建 + create_fs_parallel(&out_dir, &root, &opts)?; if args.dry_run { println!("✅ Dry‐Run 完成,没有写入磁盘。");