├── .gitignore ├── .rustfmt.toml ├── media └── image1.png ├── lib ├── mod.rs ├── either.rs ├── more_context.rs ├── path_action.rs ├── types.rs ├── errors.rs └── functions.rs ├── inputs └── test1.tree ├── dist-workspace.toml ├── LICENSE.txt ├── Cargo.toml ├── src └── main.rs ├── README.md └── .github └── workflows └── release.yml /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | 3 | Cargo.lock 4 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 80 2 | wrap_comments = true 3 | -------------------------------------------------------------------------------- /media/image1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeinred/untree/HEAD/media/image1.png -------------------------------------------------------------------------------- /lib/mod.rs: -------------------------------------------------------------------------------- 1 | #![doc = ::embed_doc_image::embed_image!("image1", "media/image1.png")] 2 | #![doc = include_str!("../README.md")] 3 | 4 | pub mod either; 5 | pub mod errors; 6 | pub mod functions; 7 | pub mod more_context; 8 | pub mod path_action; 9 | pub mod types; 10 | 11 | use either::either; 12 | pub use errors::*; 13 | pub use functions::*; 14 | pub use more_context::*; 15 | pub use path_action::*; 16 | pub use types::*; 17 | 18 | -------------------------------------------------------------------------------- /lib/either.rs: -------------------------------------------------------------------------------- 1 | /// Takes a series of expressions returning `Option`, and evaluates each one 2 | /// until finding an expression that returns `Some(item)` 3 | macro_rules! either { 4 | ($expression:expr) => { $expression }; 5 | ($first:expr, $($second:expr),+) => { 6 | match $first { 7 | Some(item) => Some(item), 8 | None => either!($($second),+) 9 | } 10 | } 11 | } 12 | 13 | pub(crate) use either; 14 | -------------------------------------------------------------------------------- /inputs/test1.tree: -------------------------------------------------------------------------------- 1 | . 2 | ├── .gitattributes 3 | ├── .gitignore 4 | ├── config 5 | │ ├── config 6 | │ │ └── default.yml 7 | │ └── match 8 | │ ├── base.yml 9 | │ └── packages 10 | ├── init.sh 11 | ├── mykey 12 | ├── packages 13 | │ └── file.txt 14 | ├── reset.sh 15 | ├── run_github.sh 16 | └── runtime 17 | ├── icon_no_backgroundv2.png 18 | ├── iconv2.png 19 | ├── kvs 20 | │ ├── has_completed_wizard 21 | │ └── has_selected_auto_start_option 22 | ├── normalv2.ico 23 | └── wizardv2.ico 24 | -------------------------------------------------------------------------------- /dist-workspace.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["cargo:."] 3 | 4 | # Config for 'dist' 5 | [dist] 6 | # The preferred dist version to use in CI (Cargo.toml SemVer syntax) 7 | cargo-dist-version = "0.28.6" 8 | # CI backends to support 9 | ci = "github" 10 | # The installers to generate for each app 11 | installers = ["shell"] 12 | # Target platforms to build apps for (Rust target-triple syntax) 13 | targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc"] 14 | # Path that installers should place binaries in 15 | install-path = "CARGO_HOME" 16 | # Whether to install an updater program 17 | install-updater = false 18 | -------------------------------------------------------------------------------- /lib/more_context.rs: -------------------------------------------------------------------------------- 1 | /// Sometimes an error can be missing external context. For example, when trying 2 | /// to read a `Lines` struct, the function may not know what the file or stream 3 | /// was that was the source of the `Lines`. In thic case, it'll report a missing 4 | /// context. Invoking `more_context` on the result or the error allows 5 | /// additional context to be filled in by the calling function 6 | pub trait MoreContext { 7 | /// If a result or error is missing context, provide the missing context 8 | fn more_context(self, info: T) -> Self; 9 | } 10 | 11 | impl MoreContext for Result 12 | where 13 | E: MoreContext, 14 | { 15 | fn more_context(self, info: Info) -> Self { 16 | match self { 17 | Ok(val) => Ok(val), 18 | Err(err) => Err(err.more_context(info)), 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2022 Alecto Irene Perez 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | 2 | [package] 3 | authors = ["Alecto Irene Perez "] 4 | description = """untree inverts the action of tree. \ 5 | It allows you to create directory trees from a textual \ 6 | representation of the tree.""" 7 | edition = "2021" 8 | license = "MIT" 9 | name = "untree" 10 | version = "0.9.10" 11 | homepage = "https://github.com/codeinred/untree" 12 | repository = "https://github.com/codeinred/untree" 13 | documentation = "https://docs.rs/untree" 14 | readme = "README.md" 15 | 16 | # Exclude media and input files from crate 17 | exclude = ["inputs/"] 18 | 19 | 20 | allow-dirty = true 21 | 22 | [lib] 23 | name = "untree" 24 | path = "lib/mod.rs" 25 | 26 | [[bin]] 27 | name = "untree" 28 | path = "src/main.rs" 29 | required-features = ["build-binary"] 30 | 31 | # Building the binary requires clap 32 | [features] 33 | default = ["build-binary"] 34 | build-binary = ["clap"] 35 | 36 | [dependencies] 37 | colored = "2" 38 | quick-error = "2" 39 | textwrap = { version = "0.16", features = ["terminal_size"] } 40 | embed-doc-image = "0.1" 41 | clap = { version = "3.0.13", features = [ 42 | "derive", 43 | "wrap_help", 44 | ], optional = true } 45 | 46 | # The profile that 'dist' will build with 47 | [profile.dist] 48 | inherits = "release" 49 | lto = "thin" 50 | -------------------------------------------------------------------------------- /lib/path_action.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt, path::Path}; 2 | 3 | /// Type representing the context for a error. Contains a path, together with a 4 | /// `PathAction` indicating what was going on at the time of the error 5 | pub type PathContext<'a> = (&'a Path, PathAction); 6 | 7 | /// Represents a type of action that can happen on a path. Used to provide 8 | /// additional context to errors, by describing what was happening when the 9 | /// error occured. 10 | #[derive(Debug, Copy, Clone, PartialEq, Eq)] 11 | pub enum PathAction { 12 | /// Error occured when creating a file for a given path 13 | CreateFile, 14 | /// Error occured when creating a directory for a given path 15 | CreateDirectory, 16 | /// Error occured while opening a file for reading 17 | OpenFileForReading, 18 | /// Error occured while reading file 19 | ReadFile, 20 | } 21 | 22 | pub use PathAction::*; 23 | 24 | impl PathAction { 25 | /// Constructs a `PathContext` given itself, and the given context 26 | pub fn on(self, path: &'_ Path) -> PathContext<'_> { 27 | (path, self) 28 | } 29 | /// Describes a path in terms of the action that was occuring on the path 30 | pub fn describe(self, path: &impl fmt::Display) -> String { 31 | match self { 32 | CreateFile => format!("create file '{path}'"), 33 | CreateDirectory => format!("create directory '{path}'"), 34 | OpenFileForReading => format!("open '{path}' for reading"), 35 | ReadFile => format!("read file '{path}'"), 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/types.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Debug, Display}; 2 | 3 | /// Represents additional options that can be passed to `create_tree` or 4 | /// `create_path`. If options.verbose is set, print out the creation of the file 5 | /// or directory. If options.dry_run is set, print out the creation of the file 6 | /// or directory, but don't actually create it (`options.dry_run` implies 7 | /// verbose) 8 | #[derive(Clone, Copy, Debug, Default)] 9 | pub struct UntreeOptions { 10 | pub(crate) dry_run: bool, 11 | pub(crate) verbose: bool, 12 | } 13 | 14 | impl UntreeOptions { 15 | /// Create a new [`UntreeOptions`] with dry_run and verbose both set to false. 16 | /// These are the defaults. 17 | pub fn new() -> Self { 18 | Default::default() 19 | } 20 | 21 | /// Return a new [`UntreeOptions`] with dry_run set to the given value 22 | #[must_use] 23 | pub fn dry_run(self, dry_run: bool) -> UntreeOptions { 24 | Self { dry_run, ..self } 25 | } 26 | 27 | /// Return a new [`UntreeOptions`] with verbose set to the given value 28 | #[must_use] 29 | pub fn verbose(self, verbose: bool) -> UntreeOptions { 30 | Self { verbose, ..self } 31 | } 32 | 33 | /// Check whether this option specifies that untree should print out what it's doing, without actually making any files or directories 34 | pub fn is_dry_run(&self) -> bool { 35 | self.dry_run 36 | } 37 | /// Check whether untree should describe what it's doing. 38 | pub fn is_verbose(&self) -> bool { 39 | self.verbose || self.dry_run 40 | } 41 | } 42 | 43 | /// Enum used to indicate that a path should be created as a file 44 | /// (`PathKind::FilePath`) or a directory (`PathKind::Directory`) 45 | #[derive(Clone, Copy, Debug)] 46 | pub enum PathKind { 47 | FilePath, 48 | Directory, 49 | } 50 | 51 | impl Display for PathKind { 52 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 53 | write!(f, "{self:?}") 54 | } 55 | } 56 | 57 | impl Display for UntreeOptions { 58 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 59 | write!(f, "{self:?}") 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /lib/errors.rs: -------------------------------------------------------------------------------- 1 | use quick_error::quick_error; 2 | use std::{io, path::Path, path::PathBuf}; 3 | 4 | use super::*; 5 | 6 | /// Tag type used to indicate that the context for an error was standard input 7 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 8 | pub enum ReadStdinType { 9 | ReadStdin, 10 | } 11 | pub use ReadStdinType::ReadStdin; 12 | 13 | quick_error! { 14 | /// Error type for untree. Provides additional context to errors, such as 15 | /// the path type. 16 | #[derive(Debug)] 17 | pub enum Error { 18 | /// Context is missing. An `io::Error` exists, but the context associated 19 | /// with the error (where it came from) wasn't known in the current 20 | /// scope. This context should be provided by a calling function via 21 | /// `error.more_context()` 22 | MissingContext(err : io::Error) { 23 | from() 24 | } 25 | /// Error generated while reading from standard input 26 | OnStdin(err : io::Error) { 27 | context(_info: ReadStdinType, err: io::Error) 28 | -> (err) 29 | } 30 | /// Error generated while doing some action on the given path 31 | OnPath(filename: PathBuf, action: PathAction, err: io::Error) { 32 | context(info: PathContext<'a>, err: io::Error) 33 | -> (info.0.to_path_buf(), info.1, err) 34 | } 35 | } 36 | } 37 | 38 | impl MoreContext for Error { 39 | fn more_context(self, _: ReadStdinType) -> Self { 40 | use Error::*; 41 | match self { 42 | MissingContext(err) => OnStdin(err), 43 | err => err, 44 | } 45 | } 46 | } 47 | 48 | impl<'a> MoreContext<(&'a Path, PathAction)> for Error { 49 | fn more_context(self, (path, action): (&'a Path, PathAction)) -> Self { 50 | use Error::*; 51 | match self { 52 | MissingContext(err) => OnPath(path.to_path_buf(), action, err), 53 | err => err, 54 | } 55 | } 56 | } 57 | 58 | /// Result type for untree. 59 | /// `untree::Result` is a `std::result::Result`. 60 | pub type Result = std::result::Result; 61 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![cfg(feature = "build-binary")] 2 | 3 | use colored::*; 4 | use std::fs::File; 5 | use std::io::{self, BufRead, BufReader, Lines, Stdin}; 6 | use std::path::{Path, PathBuf}; 7 | use textwrap::*; 8 | 9 | use quick_error::ResultExt; 10 | 11 | use clap::{AppSettings, FromArgMatches, IntoApp, Parser}; 12 | 13 | use untree::*; 14 | 15 | fn main() { 16 | let app = Args::into_app() 17 | .global_setting(AppSettings::DeriveDisplayOrder) 18 | .global_setting(AppSettings::NextLineHelp) 19 | .setting(AppSettings::DisableHelpSubcommand) 20 | // .bold(Style::Good, true) 21 | // .bold(Style::Warning, true) 22 | // .foreground(Style::Warning, Some(Color::Green)) 23 | .max_term_width(100); 24 | 25 | let args = Args::from_arg_matches(&app.get_matches()).unwrap(); 26 | 27 | use Error::*; 28 | if let Err(err) = run(args) { 29 | match err { 30 | MissingContext(err) => { 31 | print_error("An error occured with unknown context.", err); 32 | } 33 | OnStdin(err) => { 34 | print_error("An error occured while attempting to read from standard input.", err); 35 | } 36 | OnPath(path, action, err) => { 37 | let path = path.to_str().unwrap_or("").bold(); 38 | let action = action.describe(&path); 39 | print_error( 40 | format!("An error occured while attempting to {action}."), 41 | err, 42 | ); 43 | } 44 | } 45 | 46 | std::process::exit(1); 47 | } 48 | } 49 | 50 | fn print_error(msg: impl std::fmt::Display, base_err: io::Error) { 51 | let msg = format!("{msg}\n\nCause: {base_err}"); 52 | let width = termwidth(); 53 | 54 | let msg = fill(msg.as_str(), width - 4); 55 | let msg = indent(msg.as_str(), " "); 56 | 57 | let header = "ERROR:".bold().red(); 58 | 59 | eprintln!(); 60 | eprintln!("{header}"); 61 | eprintln!("{}", msg); 62 | eprintln!(); 63 | } 64 | 65 | fn run(args: Args) -> Result<()> { 66 | let directory = args.dir.unwrap_or_else(|| "".into()); 67 | 68 | let options = UntreeOptions::new() 69 | .dry_run(args.dry_run) 70 | .verbose(args.verbose); 71 | 72 | let tree_files = &args.tree_files; 73 | 74 | if tree_files.is_empty() { 75 | eprintln!("{}", "Reading tree from standard input".bold()); 76 | create_tree(&directory, read_stdin(), options).more_context(ReadStdin) 77 | } else { 78 | for path in tree_files { 79 | let filename = path.to_str().unwrap_or(""); 80 | if filename == "-" { 81 | eprintln!("{}", "Reading tree from standard input".bold()); 82 | create_tree(&directory, read_stdin(), options) 83 | .more_context(ReadStdin)?; 84 | } else { 85 | let path = path.strip_prefix("\\").unwrap_or(path); 86 | let lines = read_lines(path)?; 87 | eprintln!( 88 | "{}", 89 | format!("Reading tree from file '{filename}'").bold() 90 | ); 91 | create_tree(&directory, lines, options) 92 | .more_context(ReadFile.on(path))?; 93 | } 94 | } 95 | Ok(()) 96 | } 97 | } 98 | 99 | fn read_stdin() -> Lines> { 100 | io::BufReader::new(io::stdin()).lines() 101 | } 102 | 103 | /// The output is wrapped in a Result to allow matching on errors 104 | /// Returns an Iterator to the Reader of the lines of the file. 105 | fn read_lines(path: &'_ Path) -> Result>> { 106 | Ok(File::open(path) 107 | .map(|file| io::BufReader::new(file).lines()) 108 | .context(OpenFileForReading.on(path))?) 109 | } 110 | 111 | /// A program to instantiate directory trees from the output of tree 112 | #[derive(Parser, Debug)] 113 | #[clap(author, version, about, long_about = None)] 114 | pub struct Args { 115 | /// Directory to use as the root of the newly generated directory 116 | /// structure. Uses current working directory if no directory is 117 | /// specified. 118 | #[clap(short, long("--dir"), parse(from_os_str))] 119 | pub dir: Option, 120 | /// List of files containing trees to be read by untree. If no files are 121 | /// specified, then the tree is read from standard input. 122 | #[clap(parse(from_os_str))] 123 | pub tree_files: Vec, 124 | 125 | /// Print the names of files and directories without creating them. 126 | /// Implies verbose. 127 | #[clap(long)] 128 | pub dry_run: bool, 129 | 130 | /// Print out the names of files and directories that untree creates. 131 | #[clap(short, long)] 132 | pub verbose: bool, 133 | } 134 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Untree: Undoing tree for fun and profit 2 | 3 | Untree inverts the action of [tree] by converting tree diagrams of directory 4 | structures back into directory structures. Given a directory structure, [tree] 5 | produces a tree diagram, and given a tree diagram, untree produces a directory 6 | structure. 7 | 8 | Let's say you have the following directory structure, created by running `tree` 9 | in the root of this project: 10 | 11 |
.
 12 | ├── Cargo.lock
 13 | ├── Cargo.toml
 14 | ├── inputs
 15 | │   └── test1.tree
 16 | ├── lib
 17 | │   ├── either.rs
 18 | │   ├── errors.rs
 19 | │   ├── functions.rs
 20 | │   ├── mod.rs
 21 | │   ├── more_context.rs
 22 | │   ├── path_action.rs
 23 | │   └── types.rs
 24 | ├── LICENSE.txt
 25 | ├── media
 26 | │   ├── image1.png
 27 | │   └── image2.png
 28 | ├── README.md
 29 | └── src
 30 |     └── main.rs
 31 | 
32 | 33 | untree can create a mirror that directory structure, just based on that input: 34 | 35 | ```bash 36 | tree | untree --dir path/to/output/dir 37 | ``` 38 | 39 | Here, `test` is the destination directory where `untree` is supposed to create 40 | files. Now, if we `tree` the newly created directory, we can see that it has the 41 | same structure as the repository: 42 | 43 |
path/to/output/dir
 44 | ├── Cargo.lock
 45 | ├── Cargo.toml
 46 | ├── inputs
 47 | │   └── test1.tree
 48 | ├── lib
 49 | │   ├── either.rs
 50 | │   ├── errors.rs
 51 | │   ├── functions.rs
 52 | │   ├── mod.rs
 53 | │   ├── more_context.rs
 54 | │   ├── path_action.rs
 55 | │   └── types.rs
 56 | ├── LICENSE.txt
 57 | ├── media
 58 | │   ├── image1.png
 59 | │   └── image2.png
 60 | ├── README.md
 61 | └── src
 62 |     └── main.rs
 63 | 
 64 | 4 directories, 15 files
65 | 66 | `untree` can also read in the tree from an input file, or you can paste it in 67 | directly since it accepts input from standard input: 68 | 69 | ![Screenshot of untree running on input from stdin. The generated file was placed in path/to/output/dir][image1] 70 | 71 | ## Motivating untree 72 | 73 | I've noticed that in the past I've had to recreate directory structures in order 74 | to answer questions or run tests on the directory. For example, [this 75 | question][stack-overflow-question] asks about ignoring certain kinds of files, 76 | and it provides a directory structure as reference. 77 | 78 | The files themselves aren't provided, nor do they need to be, but the directory 79 | structure itself _is_ relevant to the question. 80 | 81 | `untree` allows you to replicate the structure of a directory printed with tree, 82 | making it easy to answer questions about programs that traverse the directory 83 | tree. This means that untree is also good for quickly creating directory 84 | structures for the purpose of mocking input to other programs. 85 | 86 | ## Using untree as a library 87 | 88 | You can use untree as a library if you need that functionality included in your 89 | program. In order to create a tree, invoke [`create_tree`] with the given 90 | directory, `Lines` buffer, and options. 91 | 92 | These options are very simple - there's [`UntreeOptions::verbose`], which will 93 | tell [`create_tree`] and [`create_path`] to print out any directories or files 94 | that were created when set, and [`UntreeOptions::dry_run`], which will print out 95 | any directories or files without actually creating them (`dry_run` implies 96 | `verbose`). 97 | 98 | Below is an example usage: 99 | 100 | ```rust 101 | use untree::*; 102 | use std::io::{BufRead, BufReader, stdin, Lines}; 103 | 104 | let options = UntreeOptions::new() 105 | .dry_run(true) // Set dry_run to true 106 | .verbose(true); // Set verbose to true 107 | let lines = BufReader::new(stdin()).lines(); 108 | 109 | create_tree("path/to/directory", lines, options)?; 110 | 111 | # Ok::<(), Error>(()) 112 | ``` 113 | 114 | Additional functions include 115 | 116 | - [`create_path`], used to create a file or path with the given options, 117 | - [`get_entry`], used to parse a line in a tree file, 118 | - [`touch_directory`], used to create a directory, 119 | - [`touch_file`], used to touch a file (does the same thing as unix touch) 120 | 121 | The primary error type used by untree is [`Error`], which holds information 122 | about a path and the action being done on it, in addition to the normal error 123 | information provided by `io::Error`. 124 | 125 | ## User testimonials 126 | 127 | When asked about _untree_, my friend said: 128 | 129 | > I retroactively want that for my time trying to get Conan to work. It woulda 130 | > made certain things just a little less painful. 131 | 132 | — _some guy_ (He asked to be referred to as "some guy") 133 | 134 | ## Comments, feedback, or contributions are welcome! 135 | 136 | I'm in the progress of learning rust, so any feedback you have is greatly 137 | appreciated! Also, if `untree` is useful to you, please let me know! 138 | 139 | [image1]: media/image1.png 140 | [tree]: https://linux.die.net/man/1/tree 141 | [stack-overflow-question]: 142 | https://stackoverflow.com/questions/70933172/how-to-write-gitignore-so-that-it-only-includes-yaml-files-and-some-specific-fi 143 | -------------------------------------------------------------------------------- /lib/functions.rs: -------------------------------------------------------------------------------- 1 | use colored::*; 2 | use std::fs::{create_dir_all, OpenOptions}; 3 | use std::io::{BufRead, ErrorKind::AlreadyExists, Lines}; 4 | use std::iter::Iterator; 5 | use std::path::{Path, PathBuf}; 6 | 7 | use super::{PathKind::*, *}; 8 | use quick_error::ResultExt; 9 | 10 | /// Returns an entry in the tree, where the first result is the depth, and the 11 | /// second result is the file 12 | pub fn get_entry(mut entry: &str) -> (i32, &str) { 13 | let mut depth = 0; 14 | 15 | loop { 16 | match either!( 17 | entry.strip_prefix(" "), 18 | entry.strip_prefix("└── "), 19 | entry.strip_prefix("├── "), 20 | entry.strip_prefix("│ "), 21 | // Some iplementations of tree use a non-breaking space here (ua0) 22 | entry.strip_prefix("│\u{a0}\u{a0} ") 23 | ) { 24 | Some(suffix) => { 25 | entry = suffix; 26 | depth += 1; 27 | } 28 | None => return (depth, entry), 29 | } 30 | } 31 | } 32 | 33 | /// Atomically create a file, if it doesn't already exist. This is an atomic 34 | /// operation on the filesystem. If the file already exists, this function exits 35 | /// without affecting that file. 36 | pub fn touch_file(path: &Path) -> Result<()> { 37 | // create_new is used to implement creation + existence checking as an 38 | // atomic filesystem operation. 39 | 40 | // create_new is used instead of create because the program should NOT 41 | // attempt to open files that already exist. This could result in an 42 | // exception being thrown if the file is locked by another program, or 43 | // marked as read only. 44 | 45 | // write(true) is passed because new files must be created as 46 | // write-accessible. Otherwise a permissions error is thrown. 47 | 48 | // See https://doc.rust-lang.org/std/fs/struct.OpenOptions.html#method.create for more details 49 | match OpenOptions::new().write(true).create_new(true).open(path) { 50 | Ok(_) => Ok(()), 51 | Err(err) => match err.kind() { 52 | // If the file already exists, that's fine - we don't need to take 53 | // an action 54 | AlreadyExists => Ok(()), 55 | // Otherwise, we propagate the error forward 56 | _ => Err(err).context(CreateFile.on(path))?, 57 | }, 58 | } 59 | } 60 | 61 | /// Create a directory, along with any parents that haven't been created 62 | pub fn touch_directory(path: &Path) -> Result<()> { 63 | Ok(create_dir_all(path).context(CreateDirectory.on(path))?) 64 | } 65 | 66 | /// Create either a file (for `kind == PathKind::File`) or a directory (for 67 | /// `kind == PathKind::Directory`). Provides additional options in the form of 68 | /// `UntreeOptions`. 69 | /// 70 | /// If `options.verbose` is set, print out the creation of the file or 71 | /// directory. If `options.dry_run` is set, print out the creation of the file 72 | /// or directory, but don't actually create it (`options.dry_run` implies 73 | /// verbose) 74 | pub fn create_path( 75 | path: &Path, 76 | kind: PathKind, 77 | options: UntreeOptions, 78 | ) -> Result<()> { 79 | let name = path.to_str().unwrap_or(""); 80 | 81 | match (options.is_verbose(), kind) { 82 | (false, _) => {} // Print nothing if is_verbose() is false 83 | (_, FilePath) => { 84 | println!("{} {}", "touch".bold().green(), name.bold().white()) 85 | } 86 | (_, Directory) => { 87 | println!("{} -p {}", "mkdir".bold().green(), name.bold().blue()) 88 | } 89 | } 90 | 91 | match (options.dry_run, kind) { 92 | (true, _) => Ok(()), // Do nothing when dry_run is true 93 | (_, FilePath) => touch_file(path), 94 | (_, Directory) => touch_directory(path), 95 | } 96 | } 97 | 98 | fn normalize_path(path: &Path) -> PathBuf { 99 | let mut result = PathBuf::new(); 100 | let mut go_back = 0; 101 | for component in path.components() { 102 | match component.as_os_str().to_str() { 103 | Some(".") => {} 104 | Some("..") => { 105 | if !result.pop() { 106 | go_back += 1; 107 | } 108 | } 109 | _ => result.push(component), 110 | } 111 | } 112 | if go_back > 0 { 113 | let mut prefix = PathBuf::new(); 114 | for _ in 0..go_back { 115 | prefix.push(".."); 116 | } 117 | prefix.push(result); 118 | result = prefix; 119 | } 120 | result 121 | } 122 | 123 | /// Create a tree based on a sequence of lines describing the tree structure 124 | /// inside the given directory 125 | /// 126 | /// **Example:** 127 | /// 128 | /// ```rust 129 | /// use untree::*; 130 | /// use std::io::{BufRead, BufReader, stdin, Lines}; 131 | /// 132 | /// let options = UntreeOptions::new() 133 | /// .dry_run(true) 134 | /// .verbose(true); 135 | /// 136 | /// let lines = BufReader::new(stdin()).lines(); 137 | /// 138 | /// create_tree("path/to/directory", lines, options)?; 139 | /// 140 | /// # Ok::<(), Error>(()) 141 | /// ``` 142 | pub fn create_tree( 143 | directory: impl Into, 144 | mut lines: Lines, 145 | options: UntreeOptions, 146 | ) -> Result<()> { 147 | let mut path = directory.into(); 148 | 149 | let mut old_depth = 0; 150 | 151 | // Get the first line 152 | if let Some(result) = lines.next() { 153 | let line = result?; 154 | let (depth, filename) = get_entry(line.as_ref()); 155 | path.push(filename); 156 | path = normalize_path(path.as_path()); 157 | old_depth = depth; 158 | } 159 | 160 | // Get remaining lines 161 | for result in lines { 162 | let line = result?; 163 | if line.is_empty() { 164 | break; 165 | } 166 | let (depth, filename) = get_entry(line.as_ref()); 167 | if depth <= old_depth { 168 | create_path(path.as_path(), FilePath, options)?; 169 | for _ in depth..old_depth { 170 | path.pop(); 171 | } 172 | path.set_file_name(filename); 173 | } else { 174 | create_path(path.as_path(), Directory, options)?; 175 | path.push(filename); 176 | } 177 | old_depth = depth; 178 | } 179 | 180 | // Create file for last line 181 | create_path(path.as_path(), FilePath, options) 182 | } 183 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by dist: https://github.com/astral-sh/cargo-dist 2 | # 3 | # Copyright 2022-2024, axodotdev 4 | # Copyright 2025 Astral Software Inc. 5 | # SPDX-License-Identifier: MIT or Apache-2.0 6 | # 7 | # CI that: 8 | # 9 | # * checks for a Git Tag that looks like a release 10 | # * builds artifacts with dist (archives, installers, hashes) 11 | # * uploads those artifacts to temporary workflow zip 12 | # * on success, uploads the artifacts to a GitHub Release 13 | # 14 | # Note that the GitHub Release will be created with a generated 15 | # title/body based on your changelogs. 16 | 17 | name: Release 18 | permissions: 19 | "contents": "write" 20 | 21 | # This task will run whenever you push a git tag that looks like a version 22 | # like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. 23 | # Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where 24 | # PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION 25 | # must be a Cargo-style SemVer Version (must have at least major.minor.patch). 26 | # 27 | # If PACKAGE_NAME is specified, then the announcement will be for that 28 | # package (erroring out if it doesn't have the given version or isn't dist-able). 29 | # 30 | # If PACKAGE_NAME isn't specified, then the announcement will be for all 31 | # (dist-able) packages in the workspace with that version (this mode is 32 | # intended for workspaces with only one dist-able package, or with all dist-able 33 | # packages versioned/released in lockstep). 34 | # 35 | # If you push multiple tags at once, separate instances of this workflow will 36 | # spin up, creating an independent announcement for each one. However, GitHub 37 | # will hard limit this to 3 tags per commit, as it will assume more tags is a 38 | # mistake. 39 | # 40 | # If there's a prerelease-style suffix to the version, then the release(s) 41 | # will be marked as a prerelease. 42 | on: 43 | pull_request: 44 | push: 45 | tags: 46 | - '**[0-9]+.[0-9]+.[0-9]+*' 47 | 48 | jobs: 49 | # Run 'dist plan' (or host) to determine what tasks we need to do 50 | plan: 51 | runs-on: "ubuntu-22.04" 52 | outputs: 53 | val: ${{ steps.plan.outputs.manifest }} 54 | tag: ${{ !github.event.pull_request && github.ref_name || '' }} 55 | tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} 56 | publishing: ${{ !github.event.pull_request }} 57 | env: 58 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 59 | steps: 60 | - uses: actions/checkout@v4 61 | with: 62 | persist-credentials: false 63 | submodules: recursive 64 | - name: Install dist 65 | # we specify bash to get pipefail; it guards against the `curl` command 66 | # failing. otherwise `sh` won't catch that `curl` returned non-0 67 | shell: bash 68 | run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/cargo-dist/releases/download/v0.28.6/cargo-dist-installer.sh | sh" 69 | - name: Cache dist 70 | uses: actions/upload-artifact@v4 71 | with: 72 | name: cargo-dist-cache 73 | path: ~/.cargo/bin/dist 74 | # sure would be cool if github gave us proper conditionals... 75 | # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible 76 | # functionality based on whether this is a pull_request, and whether it's from a fork. 77 | # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* 78 | # but also really annoying to build CI around when it needs secrets to work right.) 79 | - id: plan 80 | run: | 81 | dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json 82 | echo "dist ran successfully" 83 | cat plan-dist-manifest.json 84 | echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" 85 | - name: "Upload dist-manifest.json" 86 | uses: actions/upload-artifact@v4 87 | with: 88 | name: artifacts-plan-dist-manifest 89 | path: plan-dist-manifest.json 90 | 91 | # Build and packages all the platform-specific things 92 | build-local-artifacts: 93 | name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) 94 | # Let the initial task tell us to not run (currently very blunt) 95 | needs: 96 | - plan 97 | if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} 98 | strategy: 99 | fail-fast: false 100 | # Target platforms/runners are computed by dist in create-release. 101 | # Each member of the matrix has the following arguments: 102 | # 103 | # - runner: the github runner 104 | # - dist-args: cli flags to pass to dist 105 | # - install-dist: expression to run to install dist on the runner 106 | # 107 | # Typically there will be: 108 | # - 1 "global" task that builds universal installers 109 | # - N "local" tasks that build each platform's binaries and platform-specific installers 110 | matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} 111 | runs-on: ${{ matrix.runner }} 112 | container: ${{ matrix.container && matrix.container.image || null }} 113 | env: 114 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 115 | BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json 116 | steps: 117 | - name: enable windows longpaths 118 | run: | 119 | git config --global core.longpaths true 120 | - uses: actions/checkout@v4 121 | with: 122 | persist-credentials: false 123 | submodules: recursive 124 | - name: Install Rust non-interactively if not already installed 125 | if: ${{ matrix.container }} 126 | run: | 127 | if ! command -v cargo > /dev/null 2>&1; then 128 | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y 129 | echo "$HOME/.cargo/bin" >> $GITHUB_PATH 130 | fi 131 | - name: Install dist 132 | run: ${{ matrix.install_dist.run }} 133 | # Get the dist-manifest 134 | - name: Fetch local artifacts 135 | uses: actions/download-artifact@v4 136 | with: 137 | pattern: artifacts-* 138 | path: target/distrib/ 139 | merge-multiple: true 140 | - name: Install dependencies 141 | run: | 142 | ${{ matrix.packages_install }} 143 | - name: Build artifacts 144 | run: | 145 | # Actually do builds and make zips and whatnot 146 | dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json 147 | echo "dist ran successfully" 148 | - id: cargo-dist 149 | name: Post-build 150 | # We force bash here just because github makes it really hard to get values up 151 | # to "real" actions without writing to env-vars, and writing to env-vars has 152 | # inconsistent syntax between shell and powershell. 153 | shell: bash 154 | run: | 155 | # Parse out what we just built and upload it to scratch storage 156 | echo "paths<> "$GITHUB_OUTPUT" 157 | dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" 158 | echo "EOF" >> "$GITHUB_OUTPUT" 159 | 160 | cp dist-manifest.json "$BUILD_MANIFEST_NAME" 161 | - name: "Upload artifacts" 162 | uses: actions/upload-artifact@v4 163 | with: 164 | name: artifacts-build-local-${{ join(matrix.targets, '_') }} 165 | path: | 166 | ${{ steps.cargo-dist.outputs.paths }} 167 | ${{ env.BUILD_MANIFEST_NAME }} 168 | 169 | # Build and package all the platform-agnostic(ish) things 170 | build-global-artifacts: 171 | needs: 172 | - plan 173 | - build-local-artifacts 174 | runs-on: "ubuntu-22.04" 175 | env: 176 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 177 | BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json 178 | steps: 179 | - uses: actions/checkout@v4 180 | with: 181 | persist-credentials: false 182 | submodules: recursive 183 | - name: Install cached dist 184 | uses: actions/download-artifact@v4 185 | with: 186 | name: cargo-dist-cache 187 | path: ~/.cargo/bin/ 188 | - run: chmod +x ~/.cargo/bin/dist 189 | # Get all the local artifacts for the global tasks to use (for e.g. checksums) 190 | - name: Fetch local artifacts 191 | uses: actions/download-artifact@v4 192 | with: 193 | pattern: artifacts-* 194 | path: target/distrib/ 195 | merge-multiple: true 196 | - id: cargo-dist 197 | shell: bash 198 | run: | 199 | dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json 200 | echo "dist ran successfully" 201 | 202 | # Parse out what we just built and upload it to scratch storage 203 | echo "paths<> "$GITHUB_OUTPUT" 204 | jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" 205 | echo "EOF" >> "$GITHUB_OUTPUT" 206 | 207 | cp dist-manifest.json "$BUILD_MANIFEST_NAME" 208 | - name: "Upload artifacts" 209 | uses: actions/upload-artifact@v4 210 | with: 211 | name: artifacts-build-global 212 | path: | 213 | ${{ steps.cargo-dist.outputs.paths }} 214 | ${{ env.BUILD_MANIFEST_NAME }} 215 | # Determines if we should publish/announce 216 | host: 217 | needs: 218 | - plan 219 | - build-local-artifacts 220 | - build-global-artifacts 221 | # Only run if we're "publishing", and only if local and global didn't fail (skipped is fine) 222 | if: ${{ always() && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} 223 | env: 224 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 225 | runs-on: "ubuntu-22.04" 226 | outputs: 227 | val: ${{ steps.host.outputs.manifest }} 228 | steps: 229 | - uses: actions/checkout@v4 230 | with: 231 | persist-credentials: false 232 | submodules: recursive 233 | - name: Install cached dist 234 | uses: actions/download-artifact@v4 235 | with: 236 | name: cargo-dist-cache 237 | path: ~/.cargo/bin/ 238 | - run: chmod +x ~/.cargo/bin/dist 239 | # Fetch artifacts from scratch-storage 240 | - name: Fetch artifacts 241 | uses: actions/download-artifact@v4 242 | with: 243 | pattern: artifacts-* 244 | path: target/distrib/ 245 | merge-multiple: true 246 | - id: host 247 | shell: bash 248 | run: | 249 | dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json 250 | echo "artifacts uploaded and released successfully" 251 | cat dist-manifest.json 252 | echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" 253 | - name: "Upload dist-manifest.json" 254 | uses: actions/upload-artifact@v4 255 | with: 256 | # Overwrite the previous copy 257 | name: artifacts-dist-manifest 258 | path: dist-manifest.json 259 | # Create a GitHub Release while uploading all files to it 260 | - name: "Download GitHub Artifacts" 261 | uses: actions/download-artifact@v4 262 | with: 263 | pattern: artifacts-* 264 | path: artifacts 265 | merge-multiple: true 266 | - name: Cleanup 267 | run: | 268 | # Remove the granular manifests 269 | rm -f artifacts/*-dist-manifest.json 270 | - name: Create GitHub Release 271 | env: 272 | PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" 273 | ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" 274 | ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" 275 | RELEASE_COMMIT: "${{ github.sha }}" 276 | run: | 277 | # Write and read notes from a file to avoid quoting breaking things 278 | echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt 279 | 280 | gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* 281 | 282 | announce: 283 | needs: 284 | - plan 285 | - host 286 | # use "always() && ..." to allow us to wait for all publish jobs while 287 | # still allowing individual publish jobs to skip themselves (for prereleases). 288 | # "host" however must run to completion, no skipping allowed! 289 | if: ${{ always() && needs.host.result == 'success' }} 290 | runs-on: "ubuntu-22.04" 291 | env: 292 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 293 | steps: 294 | - uses: actions/checkout@v4 295 | with: 296 | persist-credentials: false 297 | submodules: recursive 298 | --------------------------------------------------------------------------------